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:
yml2213
2026-10-25 14:07:44 +08:00
parent 935be7a613
commit 6fc453017c
9 changed files with 378 additions and 35 deletions
+87
View File
@@ -0,0 +1,87 @@
"""后台登录 Cookie 与滑动续期中间件。
续期只发生在「已经通过 ``get_current_user`` 的请求」上:
- ``get_current_user`` 把当前用户和「本次认证用的 Cookie Token」写进
``request.state``;
- 响应完成后,中间件读取该 Token,把 ``exp`` 推到 ``now + 24h``(不超过 7 天);
- 登录流程、登出、未认证请求、401 响应一律不续期。
"""
from __future__ import annotations
import os
from datetime import UTC, datetime
from fastapi import Request, Response
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
from .security import AccessToken, renew_access_token
SESSION_COOKIE_NAME = "access_token"
# 生产环境 HTTPS 部署时设置 COOKIE_SECURE=true
_COOKIE_SECURE = os.getenv("COOKIE_SECURE", "false").lower() == "true"
# 登出会删除 Cookie,不能再被中间件补回来。
_NO_RENEW_PATHS = frozenset({"/api/auth/logout"})
def set_session_cookie(
response: Response,
token: str,
expires_at: datetime,
*,
now: datetime | None = None,
) -> None:
"""写入会话 Cookie;有效期跟随 JWT 的绝对过期时刻。"""
current = now or datetime.now(UTC)
max_age = int((expires_at - current).total_seconds())
if max_age <= 0:
return
response.set_cookie(
key=SESSION_COOKIE_NAME,
value=token,
httponly=True,
secure=_COOKIE_SECURE,
samesite="lax",
max_age=max_age,
expires=expires_at,
path="/",
)
def clear_session_cookie(response: Response) -> None:
response.delete_cookie(key=SESSION_COOKIE_NAME, path="/")
def _should_renew(request: Request, status_code: int) -> bool:
"""只对「已认证的 API 请求 + 非 401 + 非登出」续期。"""
path = request.url.path.rstrip("/") or "/"
if status_code == 401:
return False
if not path.startswith("/api/"):
return False
if path in _NO_RENEW_PATHS:
return False
return getattr(request.state, "current_user", None) is not None
class SessionRenewalMiddleware(BaseHTTPMiddleware):
"""滑动续期:活跃请求自动延长 24h,但永不突破 7 天绝对上限。"""
async def dispatch(
self, request: Request, call_next: RequestResponseEndpoint
) -> Response:
response = await call_next(request)
if not _should_renew(request, response.status_code):
return response
token = getattr(request.state, "auth_cookie_token", None)
if not token:
return response
renewed: AccessToken | None = renew_access_token(token)
if renewed is None:
return response
set_session_cookie(response, renewed.token, renewed.expires_at)
return response