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:
@@ -18,7 +18,12 @@ from app.models.asset import Account
|
||||
from app.models.credential import Credential, LoginType
|
||||
from app.models.provider import Provider
|
||||
from app.schemas.account import AccountCreate, AccountUpdate
|
||||
from app.schemas.credential import CredentialCreate, CredentialUpdate, OtpBindRequest
|
||||
from app.schemas.credential import (
|
||||
CredentialCreate,
|
||||
CredentialUpdate,
|
||||
OtpBindRequest,
|
||||
OtpVerifyRequest,
|
||||
)
|
||||
from app.services import account_service, credential_service
|
||||
|
||||
|
||||
@@ -87,15 +92,21 @@ def test_create_requires_site(session):
|
||||
assert e.value.status_code == 400
|
||||
|
||||
|
||||
# ----------------------------- 2FA 绑定校验 ----------------------------- #
|
||||
def test_create_with_otp_requires_code(session):
|
||||
"""绑定 2FA 必须同时提供当前动态码(防 secret 手误成废条目)"""
|
||||
# ----------------------------- 2FA 录入与校验 ----------------------------- #
|
||||
def test_create_with_otp_without_code_marks_unverified(session):
|
||||
"""拿不到当前动态码也允许先录入 secret
|
||||
|
||||
真实场景下 secret 多为历史留存,录入那一刻手上并没有能出码的验证器;
|
||||
强约束只会把人挡在门外。改为存下来并标记未验证,事后补验。
|
||||
"""
|
||||
secret, _ = _otp_pair()
|
||||
with pytest.raises(HTTPException) as e:
|
||||
credential_service.create_credential(
|
||||
session, CredentialCreate(site="GitHub", otp_secret=secret)
|
||||
)
|
||||
assert e.value.status_code == 400 and "动态码" in e.value.detail
|
||||
read = credential_service.create_credential(
|
||||
session, CredentialCreate(site="GitHub", otp_secret=secret)
|
||||
)
|
||||
assert read.has_otp is True and read.otp_verified is False
|
||||
# 未验证不影响出码(系统本身就是验证器),只是不保证站点认
|
||||
otp = credential_service.current_otp(session, read.id)
|
||||
assert len(otp["code"]) == 6
|
||||
|
||||
|
||||
def test_create_with_otp_wrong_code_rejected(session):
|
||||
@@ -115,6 +126,7 @@ def test_create_with_otp_valid_code(session):
|
||||
session, CredentialCreate(site="GitHub", username="me@gmail.com", otp_secret=secret, otp_code=code)
|
||||
)
|
||||
assert read.has_otp is True
|
||||
assert read.otp_verified is True # 提供了正确动态码即视为已校验
|
||||
otp = credential_service.current_otp(session, read.id)
|
||||
assert len(otp["code"]) == 6 and otp["code"].isdigit()
|
||||
assert 1 <= otp["expires_in"] <= 30
|
||||
@@ -131,7 +143,7 @@ def test_create_with_otpauth_uri(session):
|
||||
read = credential_service.create_credential(
|
||||
session, CredentialCreate(site="GitHub", otp_secret=uri, otp_code=code)
|
||||
)
|
||||
assert read.has_otp is True
|
||||
assert read.has_otp is True and read.otp_verified is True
|
||||
row = session.get(Credential, read.id)
|
||||
assert crypto.decrypt(row.otp_secret_encrypted) == secret # 只存 secret,不存整条 URI
|
||||
|
||||
@@ -156,12 +168,70 @@ def test_bind_and_unbind_otp(session):
|
||||
read = credential_service.create_credential(session, CredentialCreate(site="GitHub"))
|
||||
secret, code = _otp_pair()
|
||||
updated = credential_service.bind_otp(session, read.id, OtpBindRequest(secret=secret, code=code))
|
||||
assert updated.has_otp is True
|
||||
# 错码解绑不了也绑不上
|
||||
assert updated.has_otp is True and updated.otp_verified is True
|
||||
# 提供了动态码就必须对:错码拒绝,且不覆盖已绑定的 secret
|
||||
with pytest.raises(HTTPException):
|
||||
credential_service.bind_otp(session, read.id, OtpBindRequest(secret=secret, code="111111"))
|
||||
credential_service.bind_otp(session, read.id, OtpBindRequest(secret="JBSWY3DPEHPK3PXP", code="111111"))
|
||||
row = session.get(Credential, read.id)
|
||||
assert crypto.decrypt(row.otp_secret_encrypted) == secret
|
||||
unbound = credential_service.unbind_otp(session, read.id)
|
||||
assert unbound.has_otp is False
|
||||
assert unbound.has_otp is False and unbound.otp_verified is False
|
||||
|
||||
|
||||
def test_bind_otp_without_code_marks_unverified(session):
|
||||
"""编辑路径同样允许留空:先存 secret,标记未验证"""
|
||||
read = credential_service.create_credential(session, CredentialCreate(site="GitHub"))
|
||||
secret, _ = _otp_pair()
|
||||
updated = credential_service.bind_otp(session, read.id, OtpBindRequest(secret=secret))
|
||||
assert updated.has_otp is True and updated.otp_verified is False
|
||||
|
||||
|
||||
def test_verify_otp_promotes_to_verified(session):
|
||||
"""后置补验:从能出码的设备取当前码,通过后解除「未验证」"""
|
||||
read = credential_service.create_credential(session, CredentialCreate(site="GitHub"))
|
||||
secret, code = _otp_pair()
|
||||
credential_service.bind_otp(session, read.id, OtpBindRequest(secret=secret))
|
||||
updated = credential_service.verify_otp(session, read.id, OtpVerifyRequest(code=code))
|
||||
assert updated.otp_verified is True
|
||||
assert session.get(Credential, read.id).otp_verified_at is not None
|
||||
|
||||
|
||||
def test_verify_otp_wrong_code_keeps_unverified(session):
|
||||
read = credential_service.create_credential(session, CredentialCreate(site="GitHub"))
|
||||
secret, _ = _otp_pair()
|
||||
credential_service.bind_otp(session, read.id, OtpBindRequest(secret=secret))
|
||||
with pytest.raises(HTTPException) as e:
|
||||
credential_service.verify_otp(session, read.id, OtpVerifyRequest(code="000000"))
|
||||
assert e.value.status_code == 400 and "不匹配" in e.value.detail
|
||||
assert session.get(Credential, read.id).otp_verified_at is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad", ["12345", "abcdef", "", "1234567"])
|
||||
def test_verify_otp_rejects_malformed_code(session, bad):
|
||||
read = credential_service.create_credential(session, CredentialCreate(site="GitHub"))
|
||||
secret, _ = _otp_pair()
|
||||
credential_service.bind_otp(session, read.id, OtpBindRequest(secret=secret))
|
||||
with pytest.raises(HTTPException) as e:
|
||||
credential_service.verify_otp(session, read.id, OtpVerifyRequest(code=bad))
|
||||
assert e.value.status_code == 400
|
||||
|
||||
|
||||
def test_verify_otp_requires_bound_secret(session):
|
||||
read = credential_service.create_credential(session, CredentialCreate(site="GitHub"))
|
||||
with pytest.raises(HTTPException) as e:
|
||||
credential_service.verify_otp(session, read.id, OtpVerifyRequest(code="123456"))
|
||||
assert e.value.status_code == 400 and "未绑定" in e.value.detail
|
||||
|
||||
|
||||
def test_rebinding_secret_resets_verified(session):
|
||||
"""换 secret 必须重新验证:旧验证状态不能顺延给新 secret"""
|
||||
read = credential_service.create_credential(session, CredentialCreate(site="GitHub"))
|
||||
secret, code = _otp_pair()
|
||||
credential_service.bind_otp(session, read.id, OtpBindRequest(secret=secret, code=code))
|
||||
assert session.get(Credential, read.id).otp_verified_at is not None
|
||||
new_secret, _ = _otp_pair()
|
||||
updated = credential_service.bind_otp(session, read.id, OtpBindRequest(secret=new_secret))
|
||||
assert updated.otp_verified is False
|
||||
|
||||
|
||||
# ----------------------------- 更新 / 删除语义 ----------------------------- #
|
||||
|
||||
Reference in New Issue
Block a user