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>
This commit is contained in:
@@ -0,0 +1,100 @@
|
||||
"""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]
|
||||
Reference in New Issue
Block a user