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:
+14
-2
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user