92b062c048
- 新增 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>
101 lines
3.8 KiB
Python
101 lines
3.8 KiB
Python
"""Session 核心:登录会话的签发与校验
|
||
|
||
存储后端为 Redis(key: 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]
|