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:
2026-07-31 21:29:02 +08:00
parent 2ab8b56a01
commit 92b062c048
24 changed files with 2771 additions and 350 deletions
+95
View File
@@ -0,0 +1,95 @@
"""API 鉴权依赖:Bearer token → session → 用户记录
UserStore/SessionStore 为模块级懒加载单例:Redis 客户端创建失败时构造传 None,
降级为进程级内存存储(仅开发/兜底,重启失效)。存储后端读写异常统一转 2001。
测试可 monkeypatch _user_store/_session_store 单例完成注入。
"""
import structlog
from fastapi import Depends, Header
from redis import asyncio as redis_async
from app.api.response import ApiError
from app.config import settings
from app.core.sessions import SessionStore, SessionStoreError
from app.core.users import UserRecord, UserStore, UserStoreError
logger = structlog.get_logger()
# 模块级懒加载单例
_user_store: UserStore | None = None
_session_store: SessionStore | None = None
def _create_redis_client() -> redis_async.Redis | None:
"""创建 redis.asyncio 客户端(decode_responses=True);失败返回 None 走内存降级"""
try:
return redis_async.from_url(settings.redis_url, decode_responses=True)
except Exception:
logger.warning("Redis 客户端创建失败,认证存储降级为内存模式", exc_info=True)
return None
def _get_user_store() -> UserStore:
"""用户存储懒加载单例"""
global _user_store
if _user_store is None:
_user_store = UserStore(_create_redis_client())
return _user_store
def _get_session_store() -> SessionStore:
"""会话存储懒加载单例"""
global _session_store
if _session_store is None:
_session_store = SessionStore(_create_redis_client())
return _session_store
def _parse_bearer(authorization: str | None) -> str | None:
"""解析 Authorization 头中的 Bearer token;缺失或格式非法返回 None"""
if not authorization:
return None
scheme, _, token = authorization.partition(" ")
if scheme.lower() != "bearer" or not token.strip():
return None
return token.strip()
async def _resolve_user(authorization: str | None) -> tuple[UserRecord, str]:
"""Bearer token → session → 用户记录(不拦截 must_change_password
凭证缺失/无效/用户不存在抛 ApiError(1005);存储后端异常抛 ApiError(2001)。
供 get_current_user 与改密/退出端点共用(后者须对 must_change_password 用户放行)。
"""
token = _parse_bearer(authorization)
if token is None:
raise ApiError(1005, "未认证或凭证无效")
try:
session = await _get_session_store().get(token)
if session is None:
raise ApiError(1005, "未认证或凭证无效")
user = await _get_user_store().get(session["username"])
except (SessionStoreError, UserStoreError) as exc:
raise ApiError(2001, "认证服务暂不可用") from exc
if user is None:
raise ApiError(1005, "未认证或凭证无效")
return user, token
async def get_current_user(authorization: str | None = Header(None)) -> UserRecord:
"""鉴权依赖:校验 Bearer token 并返回当前用户记录
must_change_password 用户被拦截(1006),须先经 POST /auth/password 改密。
"""
user, _ = await _resolve_user(authorization)
if user.must_change_password:
raise ApiError(1006, "首次登录须先修改密码")
return user
async def require_admin(user: UserRecord = Depends(get_current_user)) -> UserRecord:
"""鉴权依赖:在 get_current_user 之上要求 admin 角色"""
if user.role != "admin":
raise ApiError(1006, "权限不足")
return user