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:
@@ -2,7 +2,7 @@
|
||||
|
||||
模拟旧库(accounts 无 credential_id 列)→ 跑迁移 → 断言:
|
||||
密文原样搬入、site 取平台显示名、账号关联回填、原字段置空、重跑幂等、
|
||||
无密码账号不产生空条目。
|
||||
无密码账号不产生空条目,以及旧 credentials 表补 otp_verified_at 列并回填。
|
||||
|
||||
运行:.venv/bin/pytest tests/test_credential_migration.py -v
|
||||
"""
|
||||
@@ -160,3 +160,58 @@ def test_migration_adds_credential_id_column(legacy_engine):
|
||||
with engine.connect() as conn:
|
||||
indexes = {ix["name"] for ix in sa.inspect(conn).get_indexes("accounts")}
|
||||
assert "ix_accounts_credential_id" in indexes
|
||||
|
||||
|
||||
# 旧结构 credentials 表:无 otp_verified_at 列(当时绑定强制校验过动态码)
|
||||
OLD_CREDENTIALS_DDL = """
|
||||
CREATE TABLE credentials (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
site VARCHAR NOT NULL,
|
||||
username VARCHAR,
|
||||
login_type VARCHAR NOT NULL,
|
||||
oauth_provider VARCHAR,
|
||||
password_encrypted VARCHAR,
|
||||
otp_secret_encrypted VARCHAR,
|
||||
url VARCHAR,
|
||||
note VARCHAR,
|
||||
created_at DATETIME NOT NULL,
|
||||
updated_at DATETIME NOT NULL
|
||||
)
|
||||
"""
|
||||
|
||||
|
||||
def test_migration_backfills_otp_verified(legacy_engine):
|
||||
"""旧库 credentials 缺列时自动补列,并把已绑定条目回填为「已验证」
|
||||
|
||||
这些条目在旧版是校验过当前动态码才落库的,若回填为 NULL 会被前端全线误报。
|
||||
"""
|
||||
engine = legacy_engine
|
||||
now = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
with engine.begin() as conn:
|
||||
conn.execute(sa.text("DROP TABLE credentials"))
|
||||
conn.execute(sa.text(OLD_CREDENTIALS_DDL))
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"INSERT INTO credentials (site, login_type, otp_secret_encrypted, created_at, updated_at) "
|
||||
"VALUES ('GitHub', 'PASSWORD', 'legacy-cipher-text', :now, :now)"
|
||||
),
|
||||
{"now": now},
|
||||
)
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"INSERT INTO credentials (site, login_type, otp_secret_encrypted, created_at, updated_at) "
|
||||
"VALUES ('Forum', 'PASSWORD', NULL, :now, :now)"
|
||||
),
|
||||
{"now": now},
|
||||
)
|
||||
|
||||
db._migrate_assets_db()
|
||||
|
||||
with engine.connect() as conn:
|
||||
cols = {c["name"] for c in sa.inspect(conn).get_columns("credentials")}
|
||||
assert "otp_verified_at" in cols
|
||||
rows = conn.execute(
|
||||
sa.text("SELECT site, otp_verified_at FROM credentials ORDER BY id")
|
||||
).fetchall()
|
||||
assert rows[0][1] is not None # 已绑 2FA → 视为已验证
|
||||
assert rows[1][1] is None # 未绑定 → 保持空
|
||||
|
||||
Reference in New Issue
Block a user