credentials 表为登录凭据唯一事实源(站点×登录方式,含 oauth/2FA);账号密码读写重定向凭据层并幂等迁移历史数据;TOTP 按 RFC6238 零依赖自实现,绑定需当前动态码校验;Key Escrow 防 MASTER_KEY 遗失;前端新增凭据库视图与账号 2FA 联动。64 pytest + 16 浏览器端到端验证通过。
113 lines
4.2 KiB
Python
113 lines
4.2 KiB
Python
"""TOTP(RFC 6238)自实现测试
|
||
|
||
官方向量锚定正确性(RFC 6238 附录 B:SHA1、20 字节 ASCII secret
|
||
"12345678901234567890"),覆盖:码生成、剩余秒数、录入校验窗口、
|
||
otpauth URI 解析/重建、随机 secret 可用性。
|
||
|
||
运行:.venv/bin/pytest tests/test_totp.py -v
|
||
"""
|
||
|
||
import pytest
|
||
|
||
from app.core import totp
|
||
|
||
# RFC 6238 附录 B 官方向量 secret 的 base32 形式
|
||
RFC_SECRET_B32 = "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ"
|
||
# (时刻 T, 官方 8 位码);本实现为 6 位 = 8 位码取模 10^6 补零
|
||
RFC_VECTORS = [
|
||
(59, "94287082"),
|
||
(1111111109, "07081804"),
|
||
(1111111111, "14050471"),
|
||
(1234567890, "89005924"),
|
||
(2000000000, "69279037"),
|
||
(20000000000, "65353130"),
|
||
]
|
||
|
||
|
||
@pytest.mark.parametrize("ts,code8", RFC_VECTORS)
|
||
def test_rfc6238_official_vectors(ts, code8):
|
||
"""逐条对齐 RFC 6238 附录 B 官方向量(6 位截断)"""
|
||
code6, _ = totp.totp_at(RFC_SECRET_B32, ts=ts)
|
||
assert code6 == str(int(code8) % 10**6).zfill(6)
|
||
|
||
|
||
def test_expires_in():
|
||
"""剩余秒数:T=59 窗口已过 29s 剩 1s;T=60 新窗口剩 30s"""
|
||
_, left = totp.totp_at(RFC_SECRET_B32, ts=59)
|
||
assert left == 1
|
||
_, left = totp.totp_at(RFC_SECRET_B32, ts=60)
|
||
assert left == 30
|
||
|
||
|
||
def test_verify_window_tolerance():
|
||
"""录入校验容忍 ±1 步进时钟偏差,隔两个窗口必须失败"""
|
||
code, _ = totp.totp_at(RFC_SECRET_B32, ts=59) # counter=1
|
||
assert totp.verify(RFC_SECRET_B32, code, ts=59)
|
||
assert totp.verify(RFC_SECRET_B32, code, ts=61) # counter=2,窗口含 1
|
||
assert totp.verify(RFC_SECRET_B32, code, ts=80) # counter=2
|
||
assert not totp.verify(RFC_SECRET_B32, code, ts=150) # counter=5,超出窗口
|
||
|
||
|
||
def test_verify_rejects_bad_input():
|
||
assert not totp.verify(RFC_SECRET_B32, "12345") # 位数不对
|
||
assert not totp.verify(RFC_SECRET_B32, "abcdef") # 非数字
|
||
assert not totp.verify(RFC_SECRET_B32, "000000", ts=59) # 错误码
|
||
assert not totp.verify(RFC_SECRET_B32, "", ts=59)
|
||
|
||
|
||
def test_b32decode_tolerant():
|
||
"""小写/空白/缺填充都应正确解码"""
|
||
raw = totp.b32decode(RFC_SECRET_B32)
|
||
assert totp.b32decode(RFC_SECRET_B32.lower()) == raw
|
||
assert totp.b32decode("gezdg nbvgy 3tqoj qgezd gnbvg y3tqo jq") == raw
|
||
assert totp.b32decode(RFC_SECRET_B32.rstrip("=")) == raw
|
||
|
||
|
||
def test_parse_otpauth_uri_full():
|
||
uri = (
|
||
"otpauth://totp/GitHub:me@example.com"
|
||
"?secret=JBSWY3DPEHPK3PXP&issuer=GitHub&algorithm=SHA1&period=30&digits=6"
|
||
)
|
||
p = totp.parse_otpauth_uri(uri)
|
||
assert p["secret"] == "JBSWY3DPEHPK3PXP"
|
||
assert p["issuer"] == "GitHub"
|
||
assert p["account"] == "me@example.com"
|
||
|
||
|
||
def test_parse_otpauth_uri_label_only():
|
||
"""无 issuer 参数时从 Label 前缀提取,account 需 URL 解码"""
|
||
p = totp.parse_otpauth_uri("otpauth://totp/Google:me%40gmail.com?secret=JBSWY3DPEHPK3PXP")
|
||
assert p["issuer"] == "Google"
|
||
assert p["account"] == "me@gmail.com"
|
||
|
||
|
||
def test_parse_otpauth_uri_rejects():
|
||
with pytest.raises(ValueError):
|
||
totp.parse_otpauth_uri("https://example.com") # 非 otpauth 协议
|
||
with pytest.raises(ValueError):
|
||
totp.parse_otpauth_uri("otpauth://hotp/x?secret=JBSWY3DPEHPK3PXP") # 非 TOTP
|
||
with pytest.raises(ValueError):
|
||
totp.parse_otpauth_uri("otpauth://totp/x?issuer=y") # 缺 secret
|
||
with pytest.raises(ValueError):
|
||
totp.parse_otpauth_uri("otpauth://totp/x?secret=JBSWY3DPEHPK3PXP&digits=8") # 非 6 位
|
||
with pytest.raises(ValueError):
|
||
totp.parse_otpauth_uri("otpauth://totp/x?secret=JBSWY3DPEHPK3PXP&period=60") # 非 30s
|
||
|
||
|
||
def test_build_otpauth_uri_roundtrip():
|
||
"""重建的 URI 可被解析回同样的 secret/issuer/account(换机导出场景)"""
|
||
uri = totp.build_otpauth_uri("JBSWY3DPEHPK3PXP", "GitHub", "me@example.com")
|
||
p = totp.parse_otpauth_uri(uri)
|
||
assert p["secret"] == "JBSWY3DPEHPK3PXP"
|
||
assert p["issuer"] == "GitHub"
|
||
assert p["account"] == "me@example.com"
|
||
|
||
|
||
def test_random_secret_usable():
|
||
"""随机 secret 生成的码应能通过自身校验(录入闭环)"""
|
||
s = totp.random_secret()
|
||
code, left = totp.totp_at(s)
|
||
assert len(code) == 6 and code.isdigit()
|
||
assert 1 <= left <= 30
|
||
assert totp.verify(s, code)
|