140 lines
4.4 KiB
Python
140 lines
4.4 KiB
Python
"""会话存储:管理 Cloudflare 验证通过后的浏览器会话状态
|
|
|
|
支持两种存储后端:
|
|
- Redis(推荐生产环境使用,支持多实例共享)
|
|
- 进程内存(默认回退,仅单实例可用)
|
|
|
|
会话包含浏览器存储状态(Cookie、localStorage 等)和 cf_clearance 值,
|
|
设置 TTL 自动过期,过期后需要重新通过 Cloudflare 挑战。
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from dataclasses import asdict, dataclass
|
|
from datetime import UTC, datetime, timedelta
|
|
from typing import Any
|
|
|
|
from app.core.config import Settings
|
|
|
|
try:
|
|
from redis.asyncio import Redis
|
|
except Exception: # pragma: no cover - optional until dependencies are installed.
|
|
Redis = Any # type: ignore[misc,assignment]
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class SessionState:
|
|
"""会话状态数据"""
|
|
session_id: str
|
|
user_agent: str
|
|
proxy_key: str
|
|
storage_state: dict[str, Any]
|
|
cf_clearance: str | None
|
|
created_at: str
|
|
expires_at: str
|
|
|
|
@property
|
|
def is_expired(self) -> bool:
|
|
"""会话是否已过期"""
|
|
return datetime.now(UTC) >= datetime.fromisoformat(self.expires_at)
|
|
|
|
|
|
class SessionStore:
|
|
"""会话存储,支持 Redis 和进程内存两种后端"""
|
|
|
|
def __init__(self, settings: Settings):
|
|
self._settings = settings
|
|
self._redis: Redis | None = None
|
|
self._memory: dict[str, SessionState] = {}
|
|
|
|
async def start(self) -> None:
|
|
"""初始化存储后端:配置了 Redis 时连接 Redis,否则使用进程内存"""
|
|
if not self._settings.redis_host:
|
|
return
|
|
|
|
from redis.asyncio import Redis as AsyncRedis
|
|
|
|
self._redis = AsyncRedis(
|
|
host=self._settings.redis_host,
|
|
port=self._settings.redis_port,
|
|
password=self._settings.redis_password,
|
|
db=self._settings.redis_db,
|
|
decode_responses=True,
|
|
)
|
|
await self._redis.ping()
|
|
|
|
async def close(self) -> None:
|
|
"""关闭存储后端连接"""
|
|
if self._redis is not None:
|
|
await self._redis.aclose()
|
|
self._redis = None
|
|
|
|
@property
|
|
def redis_enabled(self) -> bool:
|
|
"""Redis 是否已连接"""
|
|
return self._redis is not None
|
|
|
|
def _session_key(self, session_name: str) -> str:
|
|
"""生成 Redis/内存中的存储键"""
|
|
return f"{self._settings.session_namespace}:session:{session_name}"
|
|
|
|
async def get_session(self, session_name: str) -> SessionState | None:
|
|
"""获取会话状态,过期会话自动失效并返回 None"""
|
|
key = self._session_key(session_name)
|
|
if self._redis is not None:
|
|
raw = await self._redis.get(key)
|
|
if not raw:
|
|
return None
|
|
session = SessionState(**json.loads(raw))
|
|
if session.is_expired:
|
|
await self.invalidate_session(session_name)
|
|
return None
|
|
return session
|
|
|
|
session = self._memory.get(key)
|
|
if session and session.is_expired:
|
|
self._memory.pop(key, None)
|
|
return None
|
|
return session
|
|
|
|
async def save_session(
|
|
self,
|
|
session_name: str,
|
|
session_id: str,
|
|
user_agent: str,
|
|
proxy_key: str,
|
|
storage_state: dict[str, Any],
|
|
cf_clearance: str | None,
|
|
) -> SessionState:
|
|
"""保存会话状态
|
|
|
|
创建带 TTL 的会话记录,Redis 后端会自动在 TTL 到期后删除。
|
|
"""
|
|
now = datetime.now(UTC)
|
|
state = SessionState(
|
|
session_id=session_id,
|
|
user_agent=user_agent,
|
|
proxy_key=proxy_key,
|
|
storage_state=storage_state,
|
|
cf_clearance=cf_clearance,
|
|
created_at=now.isoformat(),
|
|
expires_at=(now + timedelta(seconds=self._settings.session_ttl_seconds)).isoformat(),
|
|
)
|
|
key = self._session_key(session_name)
|
|
|
|
if self._redis is not None:
|
|
ttl = self._settings.session_ttl_seconds
|
|
await self._redis.set(key, json.dumps(asdict(state)), ex=ttl)
|
|
return state
|
|
|
|
self._memory[key] = state
|
|
return state
|
|
|
|
async def invalidate_session(self, session_name: str) -> None:
|
|
"""使会话失效,删除存储中的会话记录"""
|
|
key = self._session_key(session_name)
|
|
if self._redis is not None:
|
|
await self._redis.delete(key)
|
|
return
|
|
self._memory.pop(key, None)
|