- JWT 记录 iat,exp=min(now+24h, iat+7d) - 已认证请求自动续签 httpOnly Cookie - 新增 ACCESS_TOKEN_IDLE_HOURS / ACCESS_TOKEN_MAX_HOURS
119 lines
3.8 KiB
Python
119 lines
3.8 KiB
Python
"""后台登录滑动会话:24h 空闲 + 7d 绝对上限。"""
|
|
|
|
from datetime import UTC, datetime, timedelta
|
|
from types import SimpleNamespace
|
|
|
|
from jose import jwt
|
|
from starlette.responses import Response
|
|
|
|
from web.backend import security
|
|
from web.backend.session import _should_renew, set_session_cookie
|
|
|
|
NOW = datetime.now(UTC).replace(microsecond=0)
|
|
|
|
|
|
def test_issue_token_uses_24h_idle_window():
|
|
issued = security.issue_access_token({"sub": "7"}, now=NOW)
|
|
payload = security.decode_access_token(issued.token)
|
|
|
|
assert payload is not None
|
|
assert payload["iat"] == int(NOW.timestamp())
|
|
assert issued.expires_at == NOW + timedelta(hours=24)
|
|
assert not security.is_session_expired(payload, now=NOW + timedelta(hours=23))
|
|
assert security.is_session_expired(payload, now=NOW + timedelta(hours=24))
|
|
|
|
|
|
def test_renew_extends_idle_window_but_keeps_session_start():
|
|
issued = security.issue_access_token({"sub": "7"}, now=NOW)
|
|
renewed = security.renew_access_token(issued.token, now=NOW + timedelta(hours=23))
|
|
|
|
assert renewed is not None
|
|
assert renewed.issued_at == NOW
|
|
assert renewed.expires_at == NOW + timedelta(hours=47)
|
|
payload = security.decode_access_token(renewed.token)
|
|
assert payload is not None
|
|
assert payload["iat"] == int(NOW.timestamp())
|
|
|
|
|
|
def test_renew_never_passes_seven_day_absolute_cap():
|
|
# 模拟一个已经连续续期到第 6 天 23:30 的会话(仍有效),
|
|
# 下一次续期只能把 exp 顶到 7d,不能推到 7d23h。
|
|
near_cap = jwt.encode(
|
|
{
|
|
"sub": "7",
|
|
"iat": NOW,
|
|
"exp": NOW + timedelta(days=6, hours=23, minutes=30),
|
|
},
|
|
security.SECRET_KEY_TYPED,
|
|
algorithm=security.ALGORITHM,
|
|
)
|
|
renewed = security.renew_access_token(
|
|
near_cap, now=NOW + timedelta(days=6, hours=23)
|
|
)
|
|
|
|
assert renewed is not None
|
|
assert renewed.expires_at == NOW + timedelta(days=7)
|
|
payload = security.decode_access_token(renewed.token)
|
|
assert payload is not None
|
|
assert security.is_session_expired(payload, now=NOW + timedelta(days=7))
|
|
|
|
# 已经顶到绝对上限后不再续签。
|
|
assert (
|
|
security.renew_access_token(
|
|
renewed.token, now=NOW + timedelta(days=6, hours=23, minutes=30)
|
|
)
|
|
is None
|
|
)
|
|
|
|
|
|
def test_legacy_token_without_iat_anchors_cap_to_original_issue_time():
|
|
legacy = jwt.encode(
|
|
{
|
|
"sub": "7",
|
|
"exp": NOW + timedelta(hours=1),
|
|
},
|
|
security.SECRET_KEY_TYPED,
|
|
algorithm=security.ALGORITHM,
|
|
)
|
|
|
|
renewed = security.renew_access_token(legacy, now=NOW)
|
|
|
|
assert renewed is not None
|
|
# 旧 Token 固定 24h,因此签发时间 = exp - 24h。
|
|
assert renewed.issued_at == NOW - timedelta(hours=23)
|
|
assert renewed.expires_at == NOW + timedelta(hours=24)
|
|
|
|
|
|
def test_set_session_cookie_matches_token_expiry():
|
|
response = Response()
|
|
expires_at = NOW + timedelta(hours=24)
|
|
|
|
set_session_cookie(response, "token-value", expires_at, now=NOW)
|
|
|
|
cookie = response.headers["set-cookie"].lower()
|
|
assert "access_token=token-value" in cookie
|
|
assert "httponly" in cookie
|
|
assert "samesite=lax" in cookie
|
|
assert "max-age=86400" in cookie
|
|
assert "path=/" in cookie
|
|
|
|
|
|
def test_should_renew_only_authenticated_api_requests():
|
|
request = SimpleNamespace(
|
|
url=SimpleNamespace(path="/api/accounts"),
|
|
state=SimpleNamespace(current_user=object()),
|
|
)
|
|
assert _should_renew(request, 200)
|
|
assert _should_renew(request, 403)
|
|
assert not _should_renew(request, 401)
|
|
|
|
request.url.path = "/api/auth/logout"
|
|
assert not _should_renew(request, 200)
|
|
|
|
request.url.path = "/assets/app.js"
|
|
assert not _should_renew(request, 200)
|
|
|
|
request.url.path = "/api/accounts"
|
|
request.state.current_user = None
|
|
assert not _should_renew(request, 200)
|