后端 FastAPI 六层结构(api/services/repositories/models/schemas/core), 统一响应信封、全局异常、日志脱敏;6 张表经 Alembic 迁移建库,SQL 留存 db/migration;登录鉴权含图形验证码(Redis GETDEL 防重放)、JWT、登录限流、 登出黑名单(TTL=令牌剩余有效期)、登录日志。 前端 Vue3 + TS + Element Plus 骨架,含登录页、路由守卫、请求封装与 Vite 代理。 工程配置:uv 锁定 Python 3.12、ruff 检查与格式化、editorconfig、gitattributes。 测试 184 项(单元 155 / 集成 29)全绿,浏览器端到端 8 项通过。 Co-Authored-By: Claude Code <noreply@anthropic.com>
113 lines
4.4 KiB
Python
113 lines
4.4 KiB
Python
"""JWT 签发与校验单元测试。
|
||
|
||
守的是:令牌的**有效期语义**与**版本号机制**。
|
||
这两点出错都不会在开发期暴露,但后果严重 ——
|
||
有效期算错 8 小时会表现为「刚登录就过期」或「过期了还能用」;
|
||
版本号机制失效则意味着禁用账号后旧令牌仍可访问全员薪酬。
|
||
"""
|
||
|
||
import uuid
|
||
from datetime import UTC, datetime, timedelta
|
||
|
||
import jwt
|
||
import pytest
|
||
|
||
from app.core.config import get_settings
|
||
from app.core.constants import JWT_ALGORITHM
|
||
from app.core.exceptions import AuthError
|
||
from app.core.security import create_access_token, decode_token
|
||
|
||
|
||
class TestCreateAccessToken:
|
||
"""令牌签发。"""
|
||
|
||
def test_返回令牌_jti与有效期(self) -> None:
|
||
token, jti, expires_in = create_access_token(user_id=1, token_version=1, roles=["admin"])
|
||
assert isinstance(token, str) and token.count(".") == 2 # header.payload.signature
|
||
assert len(jti) == 32
|
||
assert expires_in == get_settings().jwt_expire_minutes * 60
|
||
|
||
def test_载荷包含鉴定所需的全部字段(self) -> None:
|
||
token, jti, _ = create_access_token(user_id=7, token_version=3, roles=["admin", "user"])
|
||
payload = decode_token(token)
|
||
assert payload["sub"] == "7" # PyJWT 要求 sub 为字符串
|
||
assert payload["jti"] == jti
|
||
assert payload["ver"] == 3
|
||
assert payload["roles"] == ["admin", "user"]
|
||
|
||
def test_每次签发的jti都不同(self) -> None:
|
||
"""jti 是登出拉黑的唯一键,重复会导致误拉黑他人令牌。"""
|
||
jtis = set()
|
||
for _ in range(20):
|
||
_, jti, _ = create_access_token(user_id=1, token_version=1, roles=[])
|
||
jtis.add(jti)
|
||
assert len(jtis) == 20
|
||
|
||
def test_有效期基于UTC而非本地时间(self) -> None:
|
||
"""库列存本地时间、JWT 用 UTC,两者混算会产生 8 小时偏差。
|
||
|
||
这里断言 exp 与 UTC 当前时间的差值确实等于配置值 ——
|
||
若误用 datetime.now()(本地时间)生成,差值会差 8 小时。
|
||
"""
|
||
token, _, expires_in = create_access_token(user_id=1, token_version=1, roles=[])
|
||
exp = decode_token(token)["exp"]
|
||
drift = exp - datetime.now(UTC).timestamp()
|
||
assert abs(drift - expires_in) < 5 # 允许几秒执行耗时
|
||
|
||
|
||
class TestDecodeToken:
|
||
"""令牌解析与校验。"""
|
||
|
||
def test_正常令牌可解析(self) -> None:
|
||
token, _, _ = create_access_token(user_id=1, token_version=1, roles=["admin"])
|
||
assert decode_token(token)["sub"] == "1"
|
||
|
||
def test_已过期令牌抛AuthError(self) -> None:
|
||
"""直接构造过期令牌 —— create_access_token 的最小有效期为 1 分钟,
|
||
无法用它验证过期分支。"""
|
||
settings = get_settings()
|
||
past = datetime.now(UTC) - timedelta(hours=1)
|
||
expired = jwt.encode(
|
||
{
|
||
"sub": "1",
|
||
"jti": uuid.uuid4().hex,
|
||
"ver": 1,
|
||
"iat": past,
|
||
"exp": past + timedelta(seconds=60),
|
||
},
|
||
settings.app_secret_key,
|
||
algorithm=JWT_ALGORITHM,
|
||
)
|
||
with pytest.raises(AuthError):
|
||
decode_token(expired)
|
||
|
||
def test_签名被篡改时抛AuthError(self) -> None:
|
||
forged = jwt.encode(
|
||
{
|
||
"sub": "1",
|
||
"jti": uuid.uuid4().hex,
|
||
"ver": 1,
|
||
"exp": datetime.now(UTC) + timedelta(hours=1),
|
||
},
|
||
"wrong-secret-key-that-is-long-enough-to-pass-validation",
|
||
algorithm=JWT_ALGORITHM,
|
||
)
|
||
with pytest.raises(AuthError):
|
||
decode_token(forged)
|
||
|
||
@pytest.mark.parametrize("garbage", ["", "not.a.token", "a.b", "...."])
|
||
def test_格式非法的令牌抛AuthError而非其他异常(self, garbage: str) -> None:
|
||
"""令牌内容不可信,任何畸形输入都必须收敛为 401 而不是 500。"""
|
||
with pytest.raises(AuthError):
|
||
decode_token(garbage)
|
||
|
||
def test_算法被改为none时拒绝(self) -> None:
|
||
"""经典的 JWT 算法混淆攻击:把 alg 改成 none 伪造无签名令牌。"""
|
||
unsigned = jwt.encode(
|
||
{"sub": "1", "exp": datetime.now(UTC) + timedelta(hours=1)},
|
||
key="",
|
||
algorithm="none",
|
||
)
|
||
with pytest.raises(AuthError):
|
||
decode_token(unsigned)
|