feat(2fa): 动态码改为可选校验,未验证条目支持就地补验

痛点:secret 多为历史留存,录入那一刻手上常常没有能出码的验证器,
原先「绑定必须 secret + 当前 6 位码校验通过」直接把用户挡在门外。
改为「能验则验,没验就标」,抄错风险由角标持续提醒 + 后置补验兜住。

- 后端:新增 otp_verified_at 记账;code 可选(填了必须通过,错码 400
  且不覆盖已绑 secret);POST /{id}/otp/verify 补验转正;更换 secret
  一律重置为未验证;_migrate_assets_db 幂等补列并回填旧数据,避免升级
  后全量误报未验证
- 前端:列表区分「2FA 已验证/未验证」徽章,展开区未验证提醒条 + 就地
  补验输入;账号查看密码弹窗增加未验证提示;移除 CredentialModal.save()
  里的必填预校验(这是留空保存连请求都不发出的根因)
- 测试:pytest 74 绿(新增契约反转 + 6 个补验用例 + 迁移回填用例);
  Playwright 端到端 13/13(留空保存 → 未验证角标 → 错码 400 保持未验证
  → 正码补验转正 → 移动端 375px 无横向溢出 → 无 JS 运行时报错)
- 文档:goal-spec §1/§2/§4/§5/§6/§7/§9 同步新决策,替换旧的必填校验约定
This commit is contained in:
gouki
2026-09-18 08:40:09 +00:00
parent 49c4dc9af5
commit c5982a5706
13 changed files with 301 additions and 54 deletions
+12 -8
View File
@@ -335,6 +335,8 @@ const AccountsViewModal = {
<span class="text-[10px] text-slate-400 shrink-0">{{ otp.expiresIn }}s</span>
<button @click="copyOtpCode" class="ml-auto text-[11px] px-2 py-0.5 rounded border border-blue-300 dark:border-blue-700 text-blue-600 dark:text-blue-400 shrink-0">复制</button>
</div>
<!-- secret 未经校验:码照样能算出来,但不保证站点认 -->
<div v-if="pwd.otpId && !pwd.otpVerified" class="mt-2 text-[11px] text-amber-600 dark:text-amber-400 leading-snug">⚠ 此 2FA 的 secret 未经校验;若在该站点登录失败,请到凭据库核对补验。</div>
<div class="flex gap-2 mt-4">
<button @click="copyPwd" class="flex-1 py-1.5 rounded-lg bg-blue-600 text-white text-xs">{{ pwd.copied ? '✓ 已复制' : '复制密码' }}</button>
<button @click="closePwd" class="px-3 py-1.5 rounded-lg border border-slate-200 dark:border-slate-700 text-xs">关闭</button>
@@ -345,7 +347,7 @@ const AccountsViewModal = {
</div>`,
setup() {
const expanded = Vue.reactive({});
const pwd = Vue.reactive({ show: false, name: '', login_user: '', password: '', copied: false, countdown: 0, otpId: null, _timer: null });
const pwd = Vue.reactive({ show: false, name: '', login_user: '', password: '', copied: false, countdown: 0, otpId: null, otpVerified: false, _timer: null });
// 当前动态码(来自全局 otpState,服务端生成、secret 不下发)
const otp = Vue.computed(() => (pwd.otpId ? (otpState[pwd.otpId] || { code: '', expiresIn: 0 }) : { code: '', expiresIn: 0 }));
const singleProvider = Vue.computed(() => !!store.accountsModal.providerSlug);
@@ -420,6 +422,7 @@ const AccountsViewModal = {
if (a.has_otp && a.credential_id) {
// 验证码需看满 30s 周期,此时不自动关闭(countdown=0 不显示倒计时)
pwd.otpId = a.credential_id;
pwd.otpVerified = !!a.otp_verified;
pwd.countdown = 0;
fetchOtp(a.credential_id);
} else {
@@ -524,11 +527,11 @@ const CredentialModal = {
<fieldset class="border border-slate-200 dark:border-slate-700 rounded-lg p-3 space-y-2">
<legend class="text-xs font-medium text-slate-600 dark:text-slate-300 px-1">🛡 2FA(TOTP 动态码)</legend>
<p class="text-[11px] text-slate-400 leading-relaxed">粘贴 otpauth:// 链接或直接填 secret;需同时填写当前 6 位动态码做校验,防止 secret 录错后永远算不出正确码。</p>
<p class="text-[11px] text-slate-400 leading-relaxed">粘贴 otpauth:// 链接或直接填 secret。当前 6 位动态码能拿到就顺手填上(服务端校验,确认没抄错);拿不到就留空先存着,条目会标「未验证」,之后在列表里随时补验。</p>
<label class="block"><span class="text-xs text-slate-400">secret 或 otpauth 链接</span>
<input v-model="f.otp_secret" :placeholder="store.credentialModal.editing ? '留空不修改' : '可选'" class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm font-mono"></label>
<label v-if="f.otp_secret" class="block"><span class="text-xs text-slate-400">当前 6 位动态码 *</span>
<input v-model="f.otp_code" inputmode="numeric" maxlength="6" placeholder="如 123456" class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm font-mono tracking-widest"></label>
<label v-if="f.otp_secret" class="block"><span class="text-xs text-slate-400">当前 6 位动态码(可选)</span>
<input v-model="f.otp_code" inputmode="numeric" maxlength="6" placeholder="如 123456;拿不到就留空" class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm font-mono tracking-widest"></label>
</fieldset>
<label class="block"><span class="text-xs text-slate-500">备注</span>
@@ -571,11 +574,12 @@ const CredentialModal = {
return !!key && !oauthAll.value.some(o => o.key === key);
});
function pickOauth(key) { if (f.value) f.value.oauth_provider = key; }
// 2FA 前端预校验:填了 secret 就必须填 6 位码(服务端会再校验一次)
// 动态码为可选:填了就要求格式正确(值由服务端校验),没填则存为「未验证」。
// 不能再要求必填:secret 多为历史留存,录入时手上常常没有可用验证器。
function save() {
const form = f.value;
if (form.otp_secret && form.otp_secret.trim() && !/^\d{6}$/.test((form.otp_code || '').trim())) {
store.error = '绑定 2FA 需同时填写当前 6 位动态码';
const code = ((f.value && f.value.otp_code) || '').trim();
if (code && !/^\d{6}$/.test(code)) {
store.error = '动态码需为 6 位数字;拿不到可以留空先存着';
return;
}
saveCredential();
+14 -2
View File
@@ -322,9 +322,9 @@ async function saveCredential() {
// 密码留空 = 不修改(后端 None 语义),填了才提交
if (f.password) payload.password = f.password;
await Api.put('/credentials/' + editing, payload);
// 2FA 走独立接口(secret + 当前动态码服务端校验)
// 2FA 走独立接口(动态码可留空:有码则服务端校验,无码存为「未验证」)
if (f.otp_secret) {
await Api.put('/credentials/' + editing + '/otp', { secret: f.otp_secret, code: f.otp_code });
await Api.put('/credentials/' + editing + '/otp', { secret: f.otp_secret, code: f.otp_code || null });
}
} else {
payload.password = f.password || null;
@@ -353,6 +353,18 @@ async function unbindCredentialOtp(c) {
try { await Api.del('/credentials/' + c.id + '/otp'); await loadCredentials(); }
catch (e) { store.error = e.message; }
}
// 补验证:secret 常是历史留存,录入时手上没验证器;事后从能出码的设备取一次当前码转正
async function verifyCredentialOtp(c, code) {
const trimmed = (code || '').trim();
if (!/^\d{6}$/.test(trimmed)) { store.error = '请输入 6 位数字动态码'; return false; }
store.error = '';
try {
await Api.post('/credentials/' + c.id + '/otp/verify', { code: trimmed });
await Promise.all([loadCredentials(), loadAccounts()]);
showToast('✓ ' + c.site + ' 的 2FA 已验证');
return true;
} catch (e) { store.error = e.message; return false; }
}
/* ---------------- 复制与轻提示(toast) ---------------- */
const toast = Vue.reactive({ show: false, text: '', _timer: null });
+17 -1
View File
@@ -31,7 +31,9 @@ const VaultView = {
<div class="flex-1 min-w-0">
<div class="text-sm font-medium truncate">
{{ c.site }}
<span v-if="c.has_otp" class="text-[10px] px-1 py-0.5 ml-1 rounded bg-blue-500/10 text-blue-600 dark:text-blue-400 align-middle">2FA</span>
<span v-if="c.has_otp && c.otp_verified" class="text-[10px] px-1 py-0.5 ml-1 rounded bg-blue-500/10 text-blue-600 dark:text-blue-400 align-middle">2FA</span>
<span v-else-if="c.has_otp" title="secret 仅录入未校验,可能抄错;拿到验证码时补验"
class="text-[10px] px-1 py-0.5 ml-1 rounded bg-amber-500/10 text-amber-600 dark:text-amber-400 align-middle">2FA 未验证</span>
<span v-if="c.duplicate" class="text-[10px] px-1 py-0.5 ml-1 rounded bg-amber-500/10 text-amber-600 dark:text-amber-400 align-middle"
title="同站点同用户名已有多条,确认是否重复录入">重复?</span>
</div>
@@ -63,6 +65,14 @@ const VaultView = {
<button @click="copyOtp(c)" class="text-xs px-2 py-1 rounded-lg border border-blue-300 dark:border-blue-700 text-blue-600 dark:text-blue-400 shrink-0">复制</button>
</div>
<!-- 未校验的 2FA:持续提醒并就地补验(不阻塞录入,但也不假装它是可信的) -->
<div v-if="c.has_otp && !c.otp_verified" class="flex items-center gap-2 bg-amber-50 dark:bg-amber-900/20 rounded-lg px-3 py-2">
<span class="text-[11px] text-amber-700 dark:text-amber-300 min-w-0 flex-1 leading-snug">⚠ 此 2FA 只录了 secret、未校验过(万一抄错则码不可用)。下次能拿到验证码时补验一下。</span>
<input v-model="verifyInput[c.id]" @keyup.enter="doVerify(c)" inputmode="numeric" maxlength="6"
placeholder="当前 6 位码" class="w-[6.5rem] shrink-0 text-xs px-2 py-1 rounded border border-amber-300 dark:border-amber-700 bg-white dark:bg-slate-900 font-mono tracking-widest">
<button @click="doVerify(c)" class="text-xs px-2 py-1 rounded bg-amber-600 text-white shrink-0">验证</button>
</div>
<div class="text-xs text-slate-500 space-y-1">
<div v-if="c.url"><a :href="c.url" target="_blank" rel="noopener" class="text-blue-600 dark:text-blue-400 hover:underline break-all">登录页 ↗</a></div>
<div v-if="c.note" class="whitespace-pre-wrap break-words">{{ c.note }}</div>
@@ -106,6 +116,11 @@ const VaultView = {
setup() {
const expanded = Vue.reactive({});
const pwd = Vue.reactive({ show: false, site: '', username: '', password: '', countdown: 0, _timer: null });
// 未验证 2FA 的补验输入(按凭据 id 暂存,失败时保留用户已输入的码)
const verifyInput = Vue.reactive({});
async function doVerify(c) {
if (await verifyCredentialOtp(c, verifyInput[c.id])) verifyInput[c.id] = '';
}
function otpOf(c) {
return otpState[c.id] || { code: '', expiresIn: 0, loading: false };
@@ -161,6 +176,7 @@ const VaultView = {
Vue.onUnmounted(() => { stopAllOtp(); closePwd(); });
return {
store, Fmt, expanded, pwd, otpOf, toggle, reveal, closePwd, copyOtp, fmtTime,
verifyInput, doVerify,
reload: debouncedReload, toggleOtpFilter, copyText,
copyPwd: copyCredentialPassword, edit: openCredentialEdit,
del: deleteCredential, unbindOtp: unbindCredentialOtp,