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:
@@ -6,6 +6,11 @@
|
|||||||
# 生成方式: python -c "import secrets; print(secrets.token_urlsafe(32))"
|
# 生成方式: python -c "import secrets; print(secrets.token_urlsafe(32))"
|
||||||
JWT_SECRET_KEY=
|
JWT_SECRET_KEY=
|
||||||
|
|
||||||
|
# 后台登录会话:空闲 24h 过期,绝对上限 7d(活跃请求自动续期,不会突破 7d)
|
||||||
|
# 旧变量 ACCESS_TOKEN_EXPIRE_HOURS 仍可作为 ACCESS_TOKEN_IDLE_HOURS 的回退值。
|
||||||
|
ACCESS_TOKEN_IDLE_HOURS=24
|
||||||
|
ACCESS_TOKEN_MAX_HOURS=168
|
||||||
|
|
||||||
# 敏感字段落盘加密密钥(生产环境必须设置且长期保持不变!)
|
# 敏感字段落盘加密密钥(生产环境必须设置且长期保持不变!)
|
||||||
# 用于加密账号密码、邮箱密码、Cookie、代理密钥等数据库字段
|
# 用于加密账号密码、邮箱密码、Cookie、代理密钥等数据库字段
|
||||||
# 生成方式: python -c "import secrets; print(secrets.token_urlsafe(32))"
|
# 生成方式: python -c "import secrets; print(secrets.token_urlsafe(32))"
|
||||||
|
|||||||
@@ -110,6 +110,7 @@ python3 -c 'import secrets; print(secrets.token_urlsafe(32))'
|
|||||||
- `FISH_FIN_RECHARGE_*`:鱼翅供应商直充,只走环境变量,不入库、不回前端。
|
- `FISH_FIN_RECHARGE_*`:鱼翅供应商直充,只走环境变量,不入库、不回前端。
|
||||||
- `MAIL_ROUNDCUBE_URL`:邮件验证码服务;不填则用代码默认 Roundcube 地址。
|
- `MAIL_ROUNDCUBE_URL`:邮件验证码服务;不填则用代码默认 Roundcube 地址。
|
||||||
- `CORS_ORIGINS`、`COOKIE_SECURE`:HTTPS 部署时按实际填写。
|
- `CORS_ORIGINS`、`COOKIE_SECURE`:HTTPS 部署时按实际填写。
|
||||||
|
- `ACCESS_TOKEN_IDLE_HOURS` / `ACCESS_TOKEN_MAX_HOURS`:后台登录滑动会话,默认 24h 空闲过期、7d 绝对上限;活跃请求自动续签,但不会突破 7d。
|
||||||
|
|
||||||
应用宝扫码图和支付二维码只经后台授权接口返回。服务器只需要本仓库,不必再部署 `js_reverse`。Worker 任务数据在 `data/yyb-worker-jobs/`。
|
应用宝扫码图和支付二维码只经后台授权接口返回。服务器只需要本仓库,不必再部署 `js_reverse`。Worker 任务数据在 `data/yyb-worker-jobs/`。
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,9 @@ services:
|
|||||||
- DB_POOL_RECYCLE=${DB_POOL_RECYCLE:-1800}
|
- DB_POOL_RECYCLE=${DB_POOL_RECYCLE:-1800}
|
||||||
# JWT 密钥(生产环境务必修改,可用 python -c "import secrets; print(secrets.token_urlsafe(32))" 生成)
|
# JWT 密钥(生产环境务必修改,可用 python -c "import secrets; print(secrets.token_urlsafe(32))" 生成)
|
||||||
- JWT_SECRET_KEY=${JWT_SECRET_KEY:-}
|
- JWT_SECRET_KEY=${JWT_SECRET_KEY:-}
|
||||||
|
# 后台登录滑动会话:24h 空闲过期,7d 绝对上限。
|
||||||
|
- ACCESS_TOKEN_IDLE_HOURS=${ACCESS_TOKEN_IDLE_HOURS:-24}
|
||||||
|
- ACCESS_TOKEN_MAX_HOURS=${ACCESS_TOKEN_MAX_HOURS:-168}
|
||||||
# 敏感字段落盘加密密钥(生产环境务必长期保持不变)
|
# 敏感字段落盘加密密钥(生产环境务必长期保持不变)
|
||||||
- APP_ENCRYPTION_KEY=${APP_ENCRYPTION_KEY:-}
|
- APP_ENCRYPTION_KEY=${APP_ENCRYPTION_KEY:-}
|
||||||
# 默认管理员账号密码(仅首次启动建库时生效)
|
# 默认管理员账号密码(仅首次启动建库时生效)
|
||||||
|
|||||||
@@ -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)
|
||||||
+14
-5
@@ -8,7 +8,8 @@ from sqlalchemy.orm import Session
|
|||||||
from .database import SessionLocal, get_db
|
from .database import SessionLocal, get_db
|
||||||
from .models import User
|
from .models import User
|
||||||
from .permissions import get_user_permissions
|
from .permissions import get_user_permissions
|
||||||
from .security import decode_access_token
|
from .security import decode_access_token, is_session_expired
|
||||||
|
from .session import SESSION_COOKIE_NAME
|
||||||
|
|
||||||
# auto_error=False: 允许 token 为空(后续从 cookie 读取)
|
# auto_error=False: 允许 token 为空(后续从 cookie 读取)
|
||||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login", auto_error=False)
|
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login", auto_error=False)
|
||||||
@@ -25,13 +26,15 @@ def get_current_user(
|
|||||||
headers={"WWW-Authenticate": "Bearer"},
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
)
|
)
|
||||||
# 优先从 Authorization Bearer header 读取,回退到 httpOnly cookie
|
# 优先从 Authorization Bearer header 读取,回退到 httpOnly cookie
|
||||||
|
from_cookie = False
|
||||||
if not token:
|
if not token:
|
||||||
token = request.cookies.get("access_token")
|
token = request.cookies.get(SESSION_COOKIE_NAME)
|
||||||
|
from_cookie = True
|
||||||
if not token:
|
if not token:
|
||||||
raise credentials_exc
|
raise credentials_exc
|
||||||
try:
|
try:
|
||||||
payload = decode_access_token(token)
|
payload = decode_access_token(token)
|
||||||
if payload is None:
|
if payload is None or is_session_expired(payload):
|
||||||
raise credentials_exc
|
raise credentials_exc
|
||||||
user_id_raw = payload.get("sub")
|
user_id_raw = payload.get("sub")
|
||||||
if user_id_raw is None:
|
if user_id_raw is None:
|
||||||
@@ -43,6 +46,10 @@ def get_current_user(
|
|||||||
user = db.query(User).filter(User.id == int(user_id)).first()
|
user = db.query(User).filter(User.id == int(user_id)).first()
|
||||||
if user is None or not user.is_active:
|
if user is None or not user.is_active:
|
||||||
raise credentials_exc
|
raise credentials_exc
|
||||||
|
# 中间件据此只在「已通过认证的请求」上续签 Cookie;Bearer 客户端不写 Cookie。
|
||||||
|
request.state.current_user = user
|
||||||
|
if from_cookie:
|
||||||
|
request.state.auth_cookie_token = token
|
||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
@@ -66,11 +73,13 @@ def authenticate_websocket(websocket: WebSocket) -> User | None:
|
|||||||
Returns:
|
Returns:
|
||||||
User 如果认证成功,None 如果失败。
|
User 如果认证成功,None 如果失败。
|
||||||
"""
|
"""
|
||||||
token = websocket.cookies.get("access_token") or websocket.query_params.get("token")
|
token = websocket.cookies.get(SESSION_COOKIE_NAME) or websocket.query_params.get(
|
||||||
|
"token"
|
||||||
|
)
|
||||||
if not token:
|
if not token:
|
||||||
return None
|
return None
|
||||||
payload = decode_access_token(token)
|
payload = decode_access_token(token)
|
||||||
if not payload or not payload.get("sub"):
|
if not payload or is_session_expired(payload) or not payload.get("sub"):
|
||||||
return None
|
return None
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ from .routers import (
|
|||||||
yyb,
|
yyb,
|
||||||
)
|
)
|
||||||
from .schemas import AppInfo
|
from .schemas import AppInfo
|
||||||
|
from .session import SessionRenewalMiddleware
|
||||||
from .version import get_app_version
|
from .version import get_app_version
|
||||||
|
|
||||||
|
|
||||||
@@ -149,6 +150,9 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware):
|
|||||||
|
|
||||||
app.add_middleware(SecurityHeadersMiddleware)
|
app.add_middleware(SecurityHeadersMiddleware)
|
||||||
|
|
||||||
|
# 后台登录滑动续期:24h 空闲过期,7d 绝对上限。
|
||||||
|
app.add_middleware(SessionRenewalMiddleware)
|
||||||
|
|
||||||
# 注册路由
|
# 注册路由
|
||||||
app.include_router(auth.router)
|
app.include_router(auth.router)
|
||||||
app.include_router(users.router)
|
app.include_router(users.router)
|
||||||
|
|||||||
@@ -2,8 +2,6 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Request, Response
|
from fastapi import APIRouter, Depends, Request, Response
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
@@ -21,7 +19,7 @@ from ..schemas import (
|
|||||||
SmsCodeResponse,
|
SmsCodeResponse,
|
||||||
TokenResponse,
|
TokenResponse,
|
||||||
)
|
)
|
||||||
from ..security import ACCESS_TOKEN_EXPIRE_HOURS, create_access_token, verify_password
|
from ..security import issue_access_token, verify_password
|
||||||
from ..services.dc_auth_service import (
|
from ..services.dc_auth_service import (
|
||||||
APP_ID,
|
APP_ID,
|
||||||
CHALLENGE_PURPOSE,
|
CHALLENGE_PURPOSE,
|
||||||
@@ -37,12 +35,10 @@ from ..services.login_sms_service import (
|
|||||||
send_login_sms_code,
|
send_login_sms_code,
|
||||||
verify_login_sms_code,
|
verify_login_sms_code,
|
||||||
)
|
)
|
||||||
|
from ..session import clear_session_cookie, set_session_cookie
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/auth", tags=["认证"])
|
router = APIRouter(prefix="/api/auth", tags=["认证"])
|
||||||
|
|
||||||
# Cookie 安全配置(生产环境 HTTPS 部署时设置 COOKIE_SECURE=true)
|
|
||||||
_COOKIE_SECURE = os.getenv("COOKIE_SECURE", "false").lower() == "true"
|
|
||||||
|
|
||||||
|
|
||||||
def _find_user(db: Session, username_value: str) -> User | None:
|
def _find_user(db: Session, username_value: str) -> User | None:
|
||||||
"""用户名只 trim,不做大小写折叠、不限制字符集。
|
"""用户名只 trim,不做大小写折叠、不限制字符集。
|
||||||
@@ -75,18 +71,6 @@ def _request_origin(request: Request) -> str:
|
|||||||
return str(request.base_url).rstrip("/")
|
return str(request.base_url).rstrip("/")
|
||||||
|
|
||||||
|
|
||||||
def _set_auth_cookie(response: Response, token: str) -> None:
|
|
||||||
response.set_cookie(
|
|
||||||
key="access_token",
|
|
||||||
value=token,
|
|
||||||
httponly=True,
|
|
||||||
secure=_COOKIE_SECURE,
|
|
||||||
samesite="lax",
|
|
||||||
max_age=ACCESS_TOKEN_EXPIRE_HOURS * 3600,
|
|
||||||
path="/",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _issue_session(
|
def _issue_session(
|
||||||
db: Session,
|
db: Session,
|
||||||
response: Response,
|
response: Response,
|
||||||
@@ -94,8 +78,8 @@ def _issue_session(
|
|||||||
method: str,
|
method: str,
|
||||||
device_id: str = "",
|
device_id: str = "",
|
||||||
) -> TokenResponse:
|
) -> TokenResponse:
|
||||||
token = create_access_token({"sub": str(user.id), "role": user.role})
|
issued = issue_access_token({"sub": str(user.id), "role": user.role})
|
||||||
_set_auth_cookie(response, token)
|
set_session_cookie(response, issued.token, issued.expires_at)
|
||||||
|
|
||||||
detail = f"method={method}"
|
detail = f"method={method}"
|
||||||
if device_id:
|
if device_id:
|
||||||
@@ -112,7 +96,7 @@ def _issue_session(
|
|||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
return TokenResponse(
|
return TokenResponse(
|
||||||
access_token=token,
|
access_token=issued.token,
|
||||||
role=user.role,
|
role=user.role,
|
||||||
username=user.username,
|
username=user.username,
|
||||||
permissions=get_user_permissions(user),
|
permissions=get_user_permissions(user),
|
||||||
@@ -252,7 +236,7 @@ def logout(
|
|||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
response.delete_cookie(key="access_token", path="/")
|
clear_session_cookie(response)
|
||||||
db.add(
|
db.add(
|
||||||
AuditLog(
|
AuditLog(
|
||||||
user_id=current_user.id,
|
user_id=current_user.id,
|
||||||
|
|||||||
+140
-8
@@ -1,8 +1,18 @@
|
|||||||
"""安全模块:密码哈希 + JWT"""
|
"""安全模块:密码哈希 + JWT。
|
||||||
|
|
||||||
|
后台登录采用「滑动会话」:
|
||||||
|
|
||||||
|
- ``ACCESS_TOKEN_IDLE_HOURS``:空闲多久失效,默认 24 小时;
|
||||||
|
- ``ACCESS_TOKEN_MAX_HOURS``:从首次登录起算的绝对上限,默认 7 天。
|
||||||
|
|
||||||
|
每个带有效会话的 API 请求都会续签 Cookie,但 ``iat`` 保持不变,
|
||||||
|
因此活跃用户最多也只能维持 7 天,之后必须重新登录。
|
||||||
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import secrets
|
import secrets
|
||||||
import warnings
|
import warnings
|
||||||
|
from dataclasses import dataclass
|
||||||
from datetime import UTC, datetime, timedelta
|
from datetime import UTC, datetime, timedelta
|
||||||
|
|
||||||
import bcrypt
|
import bcrypt
|
||||||
@@ -20,7 +30,22 @@ if not SECRET_KEY:
|
|||||||
assert SECRET_KEY is not None
|
assert SECRET_KEY is not None
|
||||||
SECRET_KEY_TYPED: str = SECRET_KEY
|
SECRET_KEY_TYPED: str = SECRET_KEY
|
||||||
ALGORITHM = "HS256"
|
ALGORITHM = "HS256"
|
||||||
ACCESS_TOKEN_EXPIRE_HOURS = int(os.getenv("ACCESS_TOKEN_EXPIRE_HOURS", "24"))
|
|
||||||
|
# 兼容旧部署:只配置了 ACCESS_TOKEN_EXPIRE_HOURS 时,把它当作空闲时长。
|
||||||
|
ACCESS_TOKEN_IDLE_HOURS = int(
|
||||||
|
os.getenv("ACCESS_TOKEN_IDLE_HOURS", os.getenv("ACCESS_TOKEN_EXPIRE_HOURS", "24"))
|
||||||
|
)
|
||||||
|
ACCESS_TOKEN_MAX_HOURS = int(os.getenv("ACCESS_TOKEN_MAX_HOURS", str(7 * 24)))
|
||||||
|
ACCESS_TOKEN_EXPIRE_HOURS = ACCESS_TOKEN_IDLE_HOURS
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class AccessToken:
|
||||||
|
"""一次签发结果:Token 本体 + 会话起点 + 过期时刻。"""
|
||||||
|
|
||||||
|
token: str
|
||||||
|
issued_at: datetime
|
||||||
|
expires_at: datetime
|
||||||
|
|
||||||
|
|
||||||
def hash_password(password: str) -> str:
|
def hash_password(password: str) -> str:
|
||||||
@@ -37,13 +62,75 @@ def verify_password(plain: str, hashed: str) -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def create_access_token(
|
def _utcnow() -> datetime:
|
||||||
data: dict, expires_hours: int = ACCESS_TOKEN_EXPIRE_HOURS
|
return datetime.now(UTC).replace(microsecond=0)
|
||||||
) -> str:
|
|
||||||
|
|
||||||
|
def _as_utc(value: object) -> datetime | None:
|
||||||
|
"""把 JWT 里的时间字段(datetime / 时间戳)统一转成 UTC datetime。"""
|
||||||
|
if isinstance(value, datetime):
|
||||||
|
return (
|
||||||
|
value.replace(tzinfo=UTC) if value.tzinfo is None else value.astimezone(UTC)
|
||||||
|
)
|
||||||
|
if isinstance(value, (int, float)):
|
||||||
|
try:
|
||||||
|
return datetime.fromtimestamp(value, UTC)
|
||||||
|
except (OverflowError, OSError, ValueError):
|
||||||
|
return None
|
||||||
|
if isinstance(value, str):
|
||||||
|
try:
|
||||||
|
return datetime.fromtimestamp(float(value), UTC)
|
||||||
|
except (OverflowError, OSError, ValueError):
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _session_start(payload: dict) -> datetime | None:
|
||||||
|
"""会话起点:新 Token 用 ``iat``,旧 Token 用 ``exp - 24h`` 反推。
|
||||||
|
|
||||||
|
旧 Token 没有 ``iat``。上线后第一次续签必须把绝对上限锚定到真实登录时刻,
|
||||||
|
不能从「第一次续签」重新算 7 天,否则老会话会被额外延长。
|
||||||
|
"""
|
||||||
|
issued_at = _as_utc(payload.get("iat"))
|
||||||
|
if issued_at is not None:
|
||||||
|
return issued_at
|
||||||
|
expires_at = _as_utc(payload.get("exp"))
|
||||||
|
if expires_at is None:
|
||||||
|
return None
|
||||||
|
return expires_at - timedelta(hours=ACCESS_TOKEN_IDLE_HOURS)
|
||||||
|
|
||||||
|
|
||||||
|
def _absolute_expiry(issued_at: datetime) -> datetime:
|
||||||
|
return issued_at + timedelta(hours=ACCESS_TOKEN_MAX_HOURS)
|
||||||
|
|
||||||
|
|
||||||
|
def issue_access_token(
|
||||||
|
data: dict,
|
||||||
|
*,
|
||||||
|
issued_at: datetime | None = None,
|
||||||
|
now: datetime | None = None,
|
||||||
|
) -> AccessToken:
|
||||||
|
"""签发新会话 Token:``exp = min(now + 空闲时长, 会话起点 + 7 天)``。"""
|
||||||
|
current = _utcnow() if now is None else now.replace(microsecond=0)
|
||||||
|
started = current if issued_at is None else issued_at.replace(microsecond=0)
|
||||||
|
expire = min(
|
||||||
|
current + timedelta(hours=ACCESS_TOKEN_IDLE_HOURS),
|
||||||
|
_absolute_expiry(started),
|
||||||
|
)
|
||||||
to_encode = data.copy()
|
to_encode = data.copy()
|
||||||
expire = datetime.now(UTC) + timedelta(hours=expires_hours)
|
to_encode.update({"iat": started, "exp": expire})
|
||||||
to_encode.update({"exp": expire})
|
token = jwt.encode(to_encode, SECRET_KEY_TYPED, algorithm=ALGORITHM)
|
||||||
return jwt.encode(to_encode, SECRET_KEY_TYPED, algorithm=ALGORITHM)
|
return AccessToken(token=token, issued_at=started, expires_at=expire)
|
||||||
|
|
||||||
|
|
||||||
|
def create_access_token(
|
||||||
|
data: dict,
|
||||||
|
*,
|
||||||
|
issued_at: datetime | None = None,
|
||||||
|
now: datetime | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""兼容旧调用:只返回 Token 字符串。"""
|
||||||
|
return issue_access_token(data, issued_at=issued_at, now=now).token
|
||||||
|
|
||||||
|
|
||||||
def decode_access_token(token: str) -> dict | None:
|
def decode_access_token(token: str) -> dict | None:
|
||||||
@@ -51,3 +138,48 @@ def decode_access_token(token: str) -> dict | None:
|
|||||||
return jwt.decode(token, SECRET_KEY_TYPED, algorithms=[ALGORITHM])
|
return jwt.decode(token, SECRET_KEY_TYPED, algorithms=[ALGORITHM])
|
||||||
except JWTError:
|
except JWTError:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def is_session_expired(payload: dict, *, now: datetime | None = None) -> bool:
|
||||||
|
"""同时检查空闲过期与 7 天绝对上限。"""
|
||||||
|
current = now or _utcnow()
|
||||||
|
expires_at = _as_utc(payload.get("exp"))
|
||||||
|
if expires_at is None or current >= expires_at:
|
||||||
|
return True
|
||||||
|
issued_at = _session_start(payload)
|
||||||
|
return issued_at is not None and current >= _absolute_expiry(issued_at)
|
||||||
|
|
||||||
|
|
||||||
|
def renew_access_token(
|
||||||
|
token: str, *, now: datetime | None = None
|
||||||
|
) -> AccessToken | None:
|
||||||
|
"""续签有效会话;已到 7 天绝对上限或 Token 本身无效时返回 ``None``。
|
||||||
|
|
||||||
|
续签保留原 ``iat``,只把 ``exp`` 推到 ``now + 24h``,且不超过绝对上限。
|
||||||
|
"""
|
||||||
|
payload = decode_access_token(token)
|
||||||
|
if payload is None:
|
||||||
|
return None
|
||||||
|
current = (_utcnow() if now is None else now).replace(microsecond=0)
|
||||||
|
if is_session_expired(payload, now=current):
|
||||||
|
return None
|
||||||
|
issued_at = _session_start(payload)
|
||||||
|
if issued_at is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
absolute_expire = _absolute_expiry(issued_at)
|
||||||
|
if current >= absolute_expire:
|
||||||
|
return None
|
||||||
|
old_expire = _as_utc(payload.get("exp"))
|
||||||
|
new_expire = min(
|
||||||
|
current + timedelta(hours=ACCESS_TOKEN_IDLE_HOURS), absolute_expire
|
||||||
|
)
|
||||||
|
if old_expire is not None and new_expire <= old_expire:
|
||||||
|
# 已顶到绝对上限,或本次请求没有带来有意义的延长:不重发 Cookie。
|
||||||
|
return None
|
||||||
|
|
||||||
|
claims = payload.copy()
|
||||||
|
claims.pop("exp", None)
|
||||||
|
claims.update({"iat": issued_at, "exp": new_expire})
|
||||||
|
renewed = jwt.encode(claims, SECRET_KEY_TYPED, algorithm=ALGORITHM)
|
||||||
|
return AccessToken(token=renewed, issued_at=issued_at, expires_at=new_expire)
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user