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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user