merge: T4 刷新令牌与服务端可撤销会话(FR-ACC-05)
This commit is contained in:
Executable
+145
@@ -0,0 +1,145 @@
|
||||
"""Refresh token 链路(FR-ACC-05)—— 真实 PostgreSQL,auth 用例不涉及 LLM。
|
||||
|
||||
与 test_vertical_slice.py 不同:这里不替换 LLM provider(没人会调到它),
|
||||
只验证 dev-token → refresh 轮换 → logout 撤销的会话生命周期。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import uuid
|
||||
from collections.abc import AsyncIterator
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
|
||||
def _infra_available() -> bool:
|
||||
return bool(os.getenv("MEIDA_RUN_INTEGRATION"))
|
||||
|
||||
|
||||
pytestmark = [
|
||||
pytest.mark.integration,
|
||||
pytest.mark.skipif(
|
||||
not _infra_available(),
|
||||
reason="set MEIDA_RUN_INTEGRATION=1 with PostgreSQL and Redis running",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def client() -> AsyncIterator[httpx.AsyncClient]:
|
||||
from apps.api.main import create_app
|
||||
from packages.shared.config import get_settings
|
||||
|
||||
app = create_app(get_settings())
|
||||
transport = httpx.ASGITransport(app=app)
|
||||
async with (
|
||||
httpx.AsyncClient(transport=transport, base_url="http://test") as http,
|
||||
app.router.lifespan_context(app),
|
||||
):
|
||||
yield http
|
||||
|
||||
|
||||
async def _dev_token(client: httpx.AsyncClient) -> dict[str, Any]:
|
||||
"""每个用例用独立 device_key,避免共享用户状态互相污染。"""
|
||||
resp = await client.post(
|
||||
"/v1/auth/dev-token", json={"device_key": f"t4-{uuid.uuid4().hex[:8]}"}
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["code"] == 0
|
||||
data = body["data"]
|
||||
# 兼容性:旧字段名一字不改,新字段在场
|
||||
assert data["access_token"]
|
||||
assert data["expires_in"] > 0
|
||||
assert data["user_id"]
|
||||
assert data["refresh_token"]
|
||||
return data
|
||||
|
||||
|
||||
async def test_refresh_rotates_and_new_access_calls_quota(client: httpx.AsyncClient) -> None:
|
||||
pair = await _dev_token(client)
|
||||
|
||||
resp = await client.post("/v1/auth/refresh", json={"refresh_token": pair["refresh_token"]})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["code"] == 0
|
||||
data = resp.json()["data"]
|
||||
# 轮换的核心契约是 refresh 换新(随机串必不同)。access JWT 不比不等:
|
||||
# 同一用户同一秒签发时 claims 完全一致,token 字节级相同是正常现象,
|
||||
# 断言不等只会让用例变成秒边界的掷硬币。
|
||||
assert data["refresh_token"] != pair["refresh_token"]
|
||||
assert data["refresh_expires_in"] > 0
|
||||
|
||||
quota = await client.get(
|
||||
"/v1/quota", headers={"Authorization": f"Bearer {data['access_token']}"}
|
||||
)
|
||||
assert quota.status_code == 200
|
||||
assert quota.json()["code"] == 0
|
||||
|
||||
|
||||
async def test_old_refresh_is_single_use(client: httpx.AsyncClient) -> None:
|
||||
pair = await _dev_token(client)
|
||||
|
||||
first = await client.post("/v1/auth/refresh", json={"refresh_token": pair["refresh_token"]})
|
||||
assert first.status_code == 200
|
||||
|
||||
second = await client.post("/v1/auth/refresh", json={"refresh_token": pair["refresh_token"]})
|
||||
assert second.status_code == 401
|
||||
assert second.json()["code"] == 40105
|
||||
|
||||
|
||||
async def test_logout_revokes_the_session(client: httpx.AsyncClient) -> None:
|
||||
pair = await _dev_token(client)
|
||||
headers = {"Authorization": f"Bearer {pair['access_token']}"}
|
||||
|
||||
out = await client.post(
|
||||
"/v1/auth/logout", json={"refresh_token": pair["refresh_token"]}, headers=headers
|
||||
)
|
||||
assert out.status_code == 200
|
||||
assert out.json()["data"] == {"revoked": True}
|
||||
|
||||
# 幂等登出:同一 token 再登出一次不报错,revoked=false
|
||||
again = await client.post(
|
||||
"/v1/auth/logout", json={"refresh_token": pair["refresh_token"]}, headers=headers
|
||||
)
|
||||
assert again.status_code == 200
|
||||
assert again.json()["data"] == {"revoked": False}
|
||||
|
||||
refreshed = await client.post("/v1/auth/refresh", json={"refresh_token": pair["refresh_token"]})
|
||||
assert refreshed.status_code == 401
|
||||
assert refreshed.json()["code"] == 40105
|
||||
|
||||
|
||||
async def test_forged_refresh_token_is_rejected(client: httpx.AsyncClient) -> None:
|
||||
resp = await client.post("/v1/auth/refresh", json={"refresh_token": "forged-" + "x" * 32})
|
||||
assert resp.status_code == 401
|
||||
assert resp.json()["code"] == 40103
|
||||
|
||||
|
||||
async def test_expired_refresh_token_reports_40104() -> None:
|
||||
"""TTL=0 注入过期路径:签发即过期,refresh 必须走 40104 而非 40103/40105。"""
|
||||
from apps.api.main import create_app
|
||||
from packages.shared.config import get_settings
|
||||
|
||||
with pytest.MonkeyPatch.context() as mp:
|
||||
mp.setenv("REFRESH_TOKEN_TTL_SECONDS", "0")
|
||||
get_settings.cache_clear()
|
||||
app = create_app(get_settings())
|
||||
try:
|
||||
transport = httpx.ASGITransport(app=app)
|
||||
async with (
|
||||
httpx.AsyncClient(transport=transport, base_url="http://test") as http,
|
||||
app.router.lifespan_context(app),
|
||||
):
|
||||
pair = await _dev_token(http)
|
||||
assert pair["refresh_expires_in"] == 0
|
||||
resp = await http.post(
|
||||
"/v1/auth/refresh", json={"refresh_token": pair["refresh_token"]}
|
||||
)
|
||||
assert resp.status_code == 401
|
||||
assert resp.json()["code"] == 40104
|
||||
finally:
|
||||
# 恢复进程级配置缓存:打补丁的 Settings 不能泄漏给后续用例
|
||||
get_settings.cache_clear()
|
||||
Executable
+58
@@ -0,0 +1,58 @@
|
||||
"""refresh token 纯函数(packages/identity/tokens.py)。
|
||||
|
||||
无 IO、无配置:token 长度/字符域/唯一性、哈希稳定性与不可逆性在这里穷举,
|
||||
服务层的轮换/撤销语义由 tests/integration/test_auth_refresh.py 用真实 PG 验证。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import string
|
||||
|
||||
from packages.identity.tokens import generate_refresh_token, hash_refresh_token
|
||||
|
||||
#: token_urlsafe 的字母表:base64url(含 ``-`` ``_``),无 ``+`` ``/`` ``=``
|
||||
_URLSAFE_ALPHABET = frozenset(string.ascii_letters + string.digits + "_-")
|
||||
|
||||
|
||||
def test_token_has_fixed_length() -> None:
|
||||
# 48 字节 → base64 无填充恒 64 字符;长度恒定才好做列宽与日志脱敏
|
||||
assert len(generate_refresh_token()) == 64
|
||||
|
||||
|
||||
def test_token_stays_within_urlsafe_alphabet() -> None:
|
||||
# 必须能原样放进 JSON / URL / Header,不引入需要转义的字符
|
||||
tokens = [generate_refresh_token() for _ in range(50)]
|
||||
assert all(set(token) <= _URLSAFE_ALPHABET for token in tokens)
|
||||
|
||||
|
||||
def test_tokens_are_unique() -> None:
|
||||
tokens = [generate_refresh_token() for _ in range(100)]
|
||||
assert len(set(tokens)) == len(tokens)
|
||||
|
||||
|
||||
def test_hash_is_stable() -> None:
|
||||
# 校验路径 = 重算一次哈希再比对,稳定性是查库成立的前提
|
||||
token = generate_refresh_token()
|
||||
assert hash_refresh_token(token) == hash_refresh_token(token)
|
||||
|
||||
|
||||
def test_hash_is_lowercase_sha256_hex() -> None:
|
||||
digest = hash_refresh_token("abc")
|
||||
# sha256 恒 64 个小写 hex 字符,与 id_session.refresh_token_hash varchar(64) 对齐
|
||||
assert len(digest) == 64
|
||||
assert digest == digest.lower()
|
||||
assert set(digest) <= set(string.digits + "abcdef")
|
||||
|
||||
|
||||
def test_hash_is_not_the_plaintext_token() -> None:
|
||||
# 不可逆(≠原文):库里存的是哈希,泄漏数据库换不回可用令牌
|
||||
token = generate_refresh_token()
|
||||
digest = hash_refresh_token(token)
|
||||
assert digest != token
|
||||
assert token not in digest
|
||||
|
||||
|
||||
def test_different_tokens_hash_differently() -> None:
|
||||
assert hash_refresh_token(generate_refresh_token()) != hash_refresh_token(
|
||||
generate_refresh_token()
|
||||
)
|
||||
Reference in New Issue
Block a user