Files
QMDSearch/app/core/sessions.py
T
kplam 92b062c048 feat: 新增用户管理(用户增删改查、密码重置、角色权限、会话认证)与 API 指南
- 新增 app/api/deps.py、app/core/users.py、app/core/sessions.py:会话鉴权依赖、
  用户存储(PBKDF2-HMAC-SHA256 + 随机 salt,Redis/内存降级)、会话签发与校验(TTL 12h)
- auth.py 新增用户管理端点(列表/创建/重置密码/删除)与 admin/user 角色权限边界,
  user 访问用户管理返回 1006,禁删自己与最后一个 admin
- admin.html 新增用户管理面板(仅 admin 挂载)与 API 指南在线测试台
- Dockerfile 将 uv 放入 PATH;docker-compose 调整 qdrant 依赖为 service_started
  并移除依赖 curl 的 healthcheck(官方镜像不含 curl)
- 新增用户管理测试(users/sessions/auth_api/auth_integration),全量 461 项测试通过

Co-Authored-By: WorkBuddy <workbuddy@tencent.com>
2026-07-31 21:29:02 +08:00

101 lines
3.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Session 核心:登录会话的签发与校验
存储后端为 Rediskey: session:{token}JSON 序列化,TTL 12h);
构造时传入 None 降级为进程级内存 dict(带过期时间戳,惰性清理过期项,重启失效)。
所有 Redis 读写异常统一包装为 SessionStoreError 抛出(不静默)。
"""
import json
import secrets
import time
from redis import asyncio as redis_async
# Redis 键前缀
_SESSION_KEY_PREFIX = "session:"
class SessionStoreError(Exception):
"""Session 存储后端读写异常"""
class SessionStore:
"""会话存储:Redis 持久化(带 TTL),构造传 None 降级为内存 dict"""
TTL: int = 43200 # 会话有效期(秒),12h
def __init__(self, redis_client: redis_async.Redis | None) -> None:
# redis.asyncio 客户端(需 decode_responses=True);None → 内存降级
self._redis = redis_client
# 内存降级:token -> (payload, 过期时间戳)
self._memory: dict[str, tuple[dict[str, str], float]] = {}
@staticmethod
def _key(token: str) -> str:
return f"{_SESSION_KEY_PREFIX}{token}"
async def create(self, username: str, role: str) -> str:
"""签发会话:生成 64 位 hex token,存 session:{token} = {username, role}"""
token = secrets.token_hex(32)
payload = {"username": username, "role": role}
if self._redis is None:
self._purge_expired()
self._memory[token] = (payload, time.time() + self.TTL)
return token
try:
await self._redis.setex(self._key(token), self.TTL, json.dumps(payload, ensure_ascii=False))
except Exception as e:
raise SessionStoreError("写入会话失败") from e
return token
async def get(self, token: str) -> dict[str, str] | None:
"""校验会话:无效或已过期返回 None,有效返回 {username, role}"""
if self._redis is None:
item = self._memory.get(token)
if item is None:
return None
payload, expire_at = item
if expire_at <= time.time():
# 惰性清理过期项
del self._memory[token]
return None
return dict(payload)
try:
raw = await self._redis.get(self._key(token))
except Exception as e:
raise SessionStoreError("读取会话失败") from e
if raw is None:
return None
return json.loads(raw)
async def delete(self, token: str) -> None:
"""删除单个会话(幂等)"""
if self._redis is None:
self._memory.pop(token, None)
return
try:
await self._redis.delete(self._key(token))
except Exception as e:
raise SessionStoreError("删除会话失败") from e
async def delete_by_username(self, username: str) -> None:
"""删除指定用户的全部会话(删除用户时清理其登录态)"""
if self._redis is None:
matched = [t for t, (payload, _) in self._memory.items() if payload["username"] == username]
for token in matched:
del self._memory[token]
return
try:
async for key in self._redis.scan_iter(match=f"{_SESSION_KEY_PREFIX}*"):
raw = await self._redis.get(key)
if raw is not None and json.loads(raw).get("username") == username:
await self._redis.delete(key)
except Exception as e:
raise SessionStoreError(f"按用户删除会话失败: {username}") from e
def _purge_expired(self) -> None:
"""惰性清理内存降级模式下的过期会话"""
now = time.time()
for token in [t for t, (_, expire_at) in self._memory.items() if expire_at <= now]:
del self._memory[token]