chore: 初始化项目结构与登录鉴权模块
后端 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>
This commit is contained in:
commit
273ebf4657
101 files changed
+8725
No files matched your search
@@ -0,0 +1,112 @@
|
||||
"""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)
|
||||
Reference in new issue
Block a user