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:
@@ -5,6 +5,7 @@
|
||||
|
||||
- 明文密码经 /{id}/password 专用接口解密返回(与账号 reveal 语义一致)
|
||||
- 2FA 动态码由服务端生成(secret 永不出库),/{id}/otp 响应 no-store 防缓存残留
|
||||
- 录入 secret 与校验它解耦:拿不到当前码可先存(标记未验证),后续 /{id}/otp/verify 补验
|
||||
"""
|
||||
|
||||
from typing import List, Optional
|
||||
@@ -19,6 +20,7 @@ from app.schemas.credential import (
|
||||
CredentialRead,
|
||||
CredentialUpdate,
|
||||
OtpBindRequest,
|
||||
OtpVerifyRequest,
|
||||
)
|
||||
from app.services import credential_service
|
||||
|
||||
@@ -41,7 +43,7 @@ def list_credentials(
|
||||
"",
|
||||
response_model=CredentialRead,
|
||||
status_code=201,
|
||||
summary="创建凭据(可同时绑定 2FA,需当前动态码校验)",
|
||||
summary="创建凭据(可同时录入 2FA,动态码可选校验)",
|
||||
dependencies=[Depends(require_api_key)],
|
||||
)
|
||||
def create_credential(data: CredentialCreate, session: Session = Depends(get_session)):
|
||||
@@ -94,7 +96,7 @@ def current_otp(
|
||||
@router.put(
|
||||
"/{credential_id}/otp",
|
||||
response_model=CredentialRead,
|
||||
summary="绑定 2FA(secret + 当前动态码,校验通过才落库)",
|
||||
summary="绑定/更换 2FA(动态码可选:提供则校验通过才落库,留空存为未验证)",
|
||||
dependencies=[Depends(require_api_key)],
|
||||
)
|
||||
def bind_otp(
|
||||
@@ -103,6 +105,18 @@ def bind_otp(
|
||||
return credential_service.bind_otp(session, credential_id, data)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{credential_id}/otp/verify",
|
||||
response_model=CredentialRead,
|
||||
summary="补验证 2FA(提交当前动态码,通过后解除「未验证」标记)",
|
||||
dependencies=[Depends(require_api_key)],
|
||||
)
|
||||
def verify_otp(
|
||||
credential_id: int, data: OtpVerifyRequest, session: Session = Depends(get_session)
|
||||
):
|
||||
return credential_service.verify_otp(session, credential_id, data)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{credential_id}/otp",
|
||||
response_model=CredentialRead,
|
||||
|
||||
Reference in New Issue
Block a user