feat(auth): 后台登录改为滑动会话
- JWT 记录 iat,exp=min(now+24h, iat+7d) - 已认证请求自动续签 httpOnly Cookie - 新增 ACCESS_TOKEN_IDLE_HOURS / ACCESS_TOKEN_MAX_HOURS
This commit is contained in:
@@ -0,0 +1,118 @@
|
||||
"""后台登录滑动会话: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)
|
||||
Reference in New Issue
Block a user