2026-08-23 04:34:05 +08:00
|
|
|
|
"""
|
|
|
|
|
|
JWT 解析共享缓存测试(auth.py _cached_decode / jwt_auth_dependency)
|
|
|
|
|
|
|
|
|
|
|
|
验证:
|
|
|
|
|
|
1. 同 token 重复鉴权只解密一次(缓存命中),不同 token 独立缓存
|
|
|
|
|
|
2. TTL 过期后重新解码(条目自然失效)
|
|
|
|
|
|
3. 缓存条目 exp 已过期:命中即拒绝,不重解密(缓存不延长 token 有效期)
|
|
|
|
|
|
4. 无效 token 不缓存失败:每次都走完整验证(decode 调用计数递增)
|
|
|
|
|
|
5. 缓存 key 含密钥/算法指纹:同 token 不同密钥不串用
|
|
|
|
|
|
6. 返回值为独立对象:调用方修改不影响缓存
|
|
|
|
|
|
7. 端到端:真实 chongming_jwt 签发 -> 鉴权成功 -> 二次命中缓存
|
|
|
|
|
|
|
|
|
|
|
|
运行: uv run python test_jwt_cache.py
|
|
|
|
|
|
"""
|
|
|
|
|
|
import sys
|
|
|
|
|
|
import tempfile
|
|
|
|
|
|
import time
|
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
|
2026-10-13 17:29:38 +08:00
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "gateway" / "src"))
|
2026-08-23 04:34:05 +08:00
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent / "utils" / "worker" / "src"))
|
|
|
|
|
|
|
|
|
|
|
|
import orjson
|
|
|
|
|
|
from fastapi.security import HTTPAuthorizationCredentials
|
|
|
|
|
|
|
|
|
|
|
|
from chongming.app.core import auth as auth_mod
|
|
|
|
|
|
from chongming.app.core.auth import jwt_auth_dependency
|
|
|
|
|
|
from chongming_cache import Cache
|
|
|
|
|
|
from chongming_jwt import JWT
|
|
|
|
|
|
from chongming_config import get_config
|
|
|
|
|
|
|
|
|
|
|
|
failures = []
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def check(name, cond, detail=""):
|
|
|
|
|
|
status = "PASS" if cond else "FAIL"
|
|
|
|
|
|
print(f"[{status}] {name}" + (f" — {detail}" if detail else ""))
|
|
|
|
|
|
if not cond:
|
|
|
|
|
|
failures.append(name)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
config = get_config()
|
|
|
|
|
|
SECRET = config["security"]["secret_key"]
|
|
|
|
|
|
ALGORITHM = config["security"]["algorithm"]
|
|
|
|
|
|
|
|
|
|
|
|
# 每个测试使用独立临时 diskcache,隔离真实缓存目录
|
|
|
|
|
|
_tmp = tempfile.TemporaryDirectory()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def fresh_cache():
|
|
|
|
|
|
auth_mod._jwt_cache = Cache(backend="diskcache", cache_store_path=_tmp.name)
|
|
|
|
|
|
auth_mod._jwt_cache.clear()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def make_creds(token):
|
|
|
|
|
|
return HTTPAuthorizationCredentials(scheme="Bearer", credentials=token)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---- 1. 缓存命中与隔离 ----
|
|
|
|
|
|
def test_cache_hit_and_isolation():
|
|
|
|
|
|
fresh_cache()
|
|
|
|
|
|
|
|
|
|
|
|
class CountingJWT:
|
|
|
|
|
|
calls = 0
|
|
|
|
|
|
|
|
|
|
|
|
def __init__(self, secret, algorithm):
|
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
def decode(self, token):
|
|
|
|
|
|
CountingJWT.calls += 1
|
|
|
|
|
|
return orjson.dumps({"sub": token, "exp": int(time.time()) + 3600}).decode()
|
|
|
|
|
|
|
|
|
|
|
|
auth_mod.JWT = CountingJWT
|
|
|
|
|
|
t1, t2 = "token-1", "token-2"
|
|
|
|
|
|
p1 = jwt_auth_dependency(credentials=make_creds(t1))
|
|
|
|
|
|
p2 = jwt_auth_dependency(credentials=make_creds(t1))
|
|
|
|
|
|
p3 = jwt_auth_dependency(credentials=make_creds(t2))
|
|
|
|
|
|
check("同 token 二次鉴权命中缓存(只解密一次)", CountingJWT.calls == 2, f"calls={CountingJWT.calls}")
|
|
|
|
|
|
check("payload 解析正确", p1["sub"] == "token-1" and p2["sub"] == "token-1", str(p1))
|
|
|
|
|
|
check("不同 token 独立缓存", p3["sub"] == "token-2", str(p3))
|
|
|
|
|
|
check("缓存中已写入条目", len(auth_mod._jwt_cache) >= 2, str(len(auth_mod._jwt_cache)))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---- 2. TTL 过期后重新解码 ----
|
|
|
|
|
|
def test_ttl_expiry_redecodes():
|
|
|
|
|
|
fresh_cache()
|
|
|
|
|
|
auth_mod._JWT_CACHE_TTL = 1 # 缩短 TTL 加速测试
|
|
|
|
|
|
|
|
|
|
|
|
class TtlJWT:
|
|
|
|
|
|
calls = 0
|
|
|
|
|
|
|
|
|
|
|
|
def __init__(self, secret, algorithm):
|
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
def decode(self, token):
|
|
|
|
|
|
TtlJWT.calls += 1
|
|
|
|
|
|
return orjson.dumps({"sub": "u1", "exp": int(time.time()) + 3600}).decode()
|
|
|
|
|
|
|
|
|
|
|
|
auth_mod.JWT = TtlJWT
|
|
|
|
|
|
creds = make_creds("ttl-token")
|
|
|
|
|
|
p1 = jwt_auth_dependency(credentials=creds)
|
|
|
|
|
|
p2 = jwt_auth_dependency(credentials=creds)
|
|
|
|
|
|
check("TTL 内命中", TtlJWT.calls == 1 and p2["sub"] == "u1", f"calls={TtlJWT.calls}")
|
|
|
|
|
|
time.sleep(1.2) # 等待 TTL 过期
|
|
|
|
|
|
p3 = jwt_auth_dependency(credentials=creds)
|
|
|
|
|
|
check("TTL 过期后重新解码", TtlJWT.calls == 2 and p3["sub"] == "u1", f"calls={TtlJWT.calls}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---- 3. 缓存条目 exp 已过期:命中即拒绝,不重解密 ----
|
|
|
|
|
|
def test_cached_expired_rejected():
|
|
|
|
|
|
fresh_cache()
|
|
|
|
|
|
|
|
|
|
|
|
class NeverJWT:
|
|
|
|
|
|
calls = 0
|
|
|
|
|
|
|
|
|
|
|
|
def __init__(self, secret, algorithm):
|
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
def decode(self, token):
|
|
|
|
|
|
NeverJWT.calls += 1
|
|
|
|
|
|
raise AssertionError("不应触发解码(命中缓存即应拒绝)")
|
|
|
|
|
|
|
|
|
|
|
|
auth_mod.JWT = NeverJWT
|
|
|
|
|
|
# 预置一个 exp 已过期的缓存条目(模拟缓存写入后 token 到期)
|
|
|
|
|
|
key = auth_mod._cache_key("expired-token", SECRET, ALGORITHM)
|
|
|
|
|
|
auth_mod._jwt_cache.set(
|
|
|
|
|
|
key, {"payload": {"sub": "u1"}, "exp": int(time.time()) - 10}, expire=60
|
|
|
|
|
|
)
|
|
|
|
|
|
for i in range(2):
|
|
|
|
|
|
try:
|
|
|
|
|
|
jwt_auth_dependency(credentials=make_creds("expired-token"))
|
|
|
|
|
|
check(f"缓存过期条目第 {i+1} 次应 401", False, "未抛异常")
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
check(f"缓存过期条目第 {i+1} 次 401", getattr(e, "status_code", None) == 401, str(e))
|
|
|
|
|
|
check("过期条目命中后未重解密(两次都直接拒绝)", NeverJWT.calls == 0, f"calls={NeverJWT.calls}")
|
|
|
|
|
|
check("过期条目保留在缓存(TTL 内持续拒绝)", key in auth_mod._jwt_cache)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---- 4. 无效 token 失败不缓存 ----
|
|
|
|
|
|
def test_invalid_token_not_cached():
|
|
|
|
|
|
fresh_cache()
|
|
|
|
|
|
|
|
|
|
|
|
class BadJWT:
|
|
|
|
|
|
calls = 0
|
|
|
|
|
|
|
|
|
|
|
|
def __init__(self, secret, algorithm):
|
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
def decode(self, token):
|
|
|
|
|
|
BadJWT.calls += 1
|
|
|
|
|
|
raise ValueError("Invalid signature")
|
|
|
|
|
|
|
|
|
|
|
|
auth_mod.JWT = BadJWT
|
|
|
|
|
|
creds = make_creds("bad-token")
|
|
|
|
|
|
for _ in range(2):
|
|
|
|
|
|
try:
|
|
|
|
|
|
jwt_auth_dependency(credentials=creds)
|
|
|
|
|
|
check("无效 token 应 401", False)
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
ok = getattr(e, "status_code", None) == 401
|
|
|
|
|
|
check("无效 token 401", ok, str(e))
|
|
|
|
|
|
check("无效 token 每次都完整验证(失败不缓存)", BadJWT.calls == 2, f"calls={BadJWT.calls}")
|
|
|
|
|
|
check("缓存无失败条目", len(auth_mod._jwt_cache) == 0, str(len(auth_mod._jwt_cache)))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---- 5. 缓存 key 含密钥/算法指纹 ----
|
|
|
|
|
|
def test_key_scoped_to_secret():
|
|
|
|
|
|
fresh_cache()
|
|
|
|
|
|
|
|
|
|
|
|
class KeyJWT:
|
|
|
|
|
|
calls = 0
|
|
|
|
|
|
|
|
|
|
|
|
def __init__(self, secret, algorithm):
|
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
def decode(self, token):
|
|
|
|
|
|
KeyJWT.calls += 1
|
|
|
|
|
|
return orjson.dumps({"sub": token, "exp": int(time.time()) + 3600}).decode()
|
|
|
|
|
|
|
|
|
|
|
|
auth_mod.JWT = KeyJWT
|
|
|
|
|
|
creds = make_creds("same-token")
|
|
|
|
|
|
jwt_auth_dependency(credentials=creds) # secret A
|
|
|
|
|
|
jwt_auth_dependency(credentials=creds) # secret A -> 命中
|
|
|
|
|
|
auth_mod._cached_decode("same-token", "secret-B", ALGORITHM) # secret B -> miss
|
|
|
|
|
|
check("同 token 不同密钥独立缓存", KeyJWT.calls == 2, f"calls={KeyJWT.calls}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---- 6. 返回值独立对象:修改不影响缓存 ----
|
|
|
|
|
|
def test_payload_object_independent():
|
|
|
|
|
|
fresh_cache()
|
|
|
|
|
|
|
|
|
|
|
|
class FixedJWT:
|
|
|
|
|
|
def __init__(self, secret, algorithm):
|
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
def decode(self, token):
|
|
|
|
|
|
return orjson.dumps({"sub": "u1", "role": "admin", "exp": int(time.time()) + 3600}).decode()
|
|
|
|
|
|
|
|
|
|
|
|
auth_mod.JWT = FixedJWT
|
|
|
|
|
|
p1 = jwt_auth_dependency(credentials=make_creds("t-copy"))
|
|
|
|
|
|
p1["role"] = "hacker"
|
|
|
|
|
|
p2 = jwt_auth_dependency(credentials=make_creds("t-copy"))
|
|
|
|
|
|
check("修改返回值不污染缓存", p2["role"] == "admin", str(p2))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---- 7. 端到端:真实 JWT 签发 -> 鉴权 -> 命中缓存 ----
|
|
|
|
|
|
def test_e2e_real_jwt():
|
|
|
|
|
|
fresh_cache()
|
|
|
|
|
|
auth_mod.JWT = JWT # 恢复真实 JWT 类
|
|
|
|
|
|
jwt = JWT(SECRET, ALGORITHM)
|
|
|
|
|
|
token = jwt.encode(orjson.dumps({"sub": "1", "username": "alice"}).decode(), expires_in_seconds=3600)
|
|
|
|
|
|
creds = make_creds(token)
|
|
|
|
|
|
p1 = jwt_auth_dependency(credentials=creds)
|
|
|
|
|
|
p2 = jwt_auth_dependency(credentials=creds)
|
|
|
|
|
|
check("真实 JWT 鉴权成功", p1["sub"] == "1" and p1["username"] == "alice", str(p1))
|
|
|
|
|
|
check("二次调用命中缓存", auth_mod._cache_key(token, SECRET, ALGORITHM) in auth_mod._jwt_cache)
|
|
|
|
|
|
try:
|
|
|
|
|
|
jwt_auth_dependency(credentials=None)
|
|
|
|
|
|
check("缺 header 应 401", False)
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
check("缺 header 401", getattr(e, "status_code", None) == 401)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
test_cache_hit_and_isolation()
|
|
|
|
|
|
test_ttl_expiry_redecodes()
|
|
|
|
|
|
test_cached_expired_rejected()
|
|
|
|
|
|
test_invalid_token_not_cached()
|
|
|
|
|
|
test_key_scoped_to_secret()
|
|
|
|
|
|
test_payload_object_independent()
|
|
|
|
|
|
test_e2e_real_jwt()
|
|
|
|
|
|
|
|
|
|
|
|
_tmp.cleanup()
|
|
|
|
|
|
|
|
|
|
|
|
print()
|
|
|
|
|
|
if failures:
|
|
|
|
|
|
print(f"共 {len(failures)} 项失败: {failures}")
|
|
|
|
|
|
sys.exit(1)
|
|
|
|
|
|
print("全部通过")
|