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
+100
View File
@@ -0,0 +1,100 @@
"""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]
+191
View File
@@ -0,0 +1,191 @@
"""用户核心:用户记录存储与密码哈希
存储后端为 Rediskey: user:{username},JSON 序列化,无 TTL 持久化);
构造时传入 None 降级为进程级内存 dict(重启丢失,仅用于测试与 Redis 故障兜底)。
所有 Redis 读写异常统一包装为 UserStoreError 抛出(不静默),由上层转 2001。
密码哈希使用标准库 hashlib.pbkdf2_hmacSHA256100_000 迭代 + 16 字节随机 salt)。
"""
import hashlib
import hmac
import json
import re
import secrets
from dataclasses import asdict, dataclass
from datetime import UTC, datetime
from redis import asyncio as redis_async
from structlog.typing import FilteringBoundLogger
# 用户名规则:字母/数字/下划线/连字符,2~32 位
USERNAME_PATTERN = re.compile(r"^[a-zA-Z0-9_-]{2,32}$")
# 密码最小长度
MIN_PASSWORD_LENGTH = 8
# PBKDF2 迭代次数
_HASH_ITERATIONS = 100_000
# salt 字节数(hex 序列化后 32 字符)
_SALT_BYTES = 16
# Redis 键前缀
_USER_KEY_PREFIX = "user:"
class UserExistsError(Exception):
"""用户名已存在"""
class UserStoreError(Exception):
"""用户存储后端读写异常"""
@dataclass
class UserRecord:
"""用户记录(不含明文密码)"""
username: str
role: str # "admin" | "user"
password_hash: str # hex
salt: str # hex, 16 字节
must_change_password: bool
created_at: str # UTC ISO8601
class UserStore:
"""用户存储:Redis 持久化,构造传 None 降级为内存 dict"""
def __init__(self, redis_client: redis_async.Redis | None) -> None:
# redis.asyncio 客户端(需 decode_responses=True);None → 内存降级
self._redis = redis_client
# 内存降级:username -> JSON 字符串(与 Redis 路径同构)
self._memory: dict[str, str] = {}
@staticmethod
def _key(username: str) -> str:
return f"{_USER_KEY_PREFIX}{username}"
def hash_password(self, password: str, salt: bytes) -> str:
"""PBKDF2-HMAC-SHA256100_000 迭代),返回 hex 摘要"""
return hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, _HASH_ITERATIONS).hex()
async def create(
self,
username: str,
password: str,
role: str = "user",
must_change_password: bool = False,
) -> UserRecord:
"""创建用户
重名抛 UserExistsError;用户名不符合 USERNAME_PATTERN 或密码过短抛 ValueError。
"""
if not USERNAME_PATTERN.match(username):
raise ValueError(f"用户名非法: {username!r}(须为 2~32 位字母/数字/_/-)")
if len(password) < MIN_PASSWORD_LENGTH:
raise ValueError(f"密码长度不得少于 {MIN_PASSWORD_LENGTH}")
if await self.get(username) is not None:
raise UserExistsError(f"用户已存在: {username}")
salt = secrets.token_bytes(_SALT_BYTES)
record = UserRecord(
username=username,
role=role,
password_hash=self.hash_password(password, salt),
salt=salt.hex(),
must_change_password=must_change_password,
created_at=datetime.now(UTC).isoformat(),
)
await self._write(record)
return record
async def get(self, username: str) -> UserRecord | None:
"""按用户名查询,不存在返回 None"""
raw = await self._read_raw(username)
if raw is None:
return None
return UserRecord(**json.loads(raw))
async def list(self) -> list[UserRecord]:
"""列出全部用户(扫描 user:* 键)"""
if self._redis is None:
raws: list[str | None] = list(self._memory.values())
else:
try:
raws = [await self._redis.get(key) async for key in self._redis.scan_iter(match=f"{_USER_KEY_PREFIX}*")]
except Exception as e:
raise UserStoreError("列出用户失败") from e
return [UserRecord(**json.loads(raw)) for raw in raws if raw is not None]
async def delete(self, username: str) -> bool:
"""删除用户,返回是否删除成功(幂等:不存在返回 False)"""
if self._redis is None:
return self._memory.pop(username, None) is not None
try:
return bool(await self._redis.delete(self._key(username)))
except Exception as e:
raise UserStoreError(f"删除用户失败: {username}") from e
async def verify_password(self, username: str, password: str) -> UserRecord | None:
"""校验密码:成功返回用户记录,用户不存在或密码错误返回 None"""
record = await self.get(username)
if record is None:
return None
candidate = self.hash_password(password, bytes.fromhex(record.salt))
# 常量时间比较,防时序侧信道
if not hmac.compare_digest(candidate, record.password_hash):
return None
return record
async def set_password(self, username: str, new_password: str) -> bool:
"""重置密码:重新生成 salt 并哈希,同时清除 must_change_password 标记
用户不存在返回 False;新密码过短抛 ValueError。
"""
if len(new_password) < MIN_PASSWORD_LENGTH:
raise ValueError(f"密码长度不得少于 {MIN_PASSWORD_LENGTH}")
record = await self.get(username)
if record is None:
return False
salt = secrets.token_bytes(_SALT_BYTES)
record.salt = salt.hex()
record.password_hash = self.hash_password(new_password, salt)
record.must_change_password = False
await self._write(record)
return True
async def count_admins(self) -> int:
"""统计 admin 角色用户数"""
return sum(1 for record in await self.list() if record.role == "admin")
async def _read_raw(self, username: str) -> str | None:
if self._redis is None:
return self._memory.get(username)
try:
return await self._redis.get(self._key(username))
except Exception as e:
raise UserStoreError(f"读取用户失败: {username}") from e
async def _write(self, record: UserRecord) -> None:
raw = json.dumps(asdict(record), ensure_ascii=False)
if self._redis is None:
self._memory[record.username] = raw
return
try:
await self._redis.set(self._key(record.username), raw)
except Exception as e:
raise UserStoreError(f"写入用户失败: {record.username}") from e
async def bootstrap_admin(store: UserStore, logger: FilteringBoundLogger) -> str | None:
"""空库引导:无任何用户时创建随机密码的 admin 账号
明文密码通过 logger.warning 打印一次并返回(仅此一次机会);
已有用户时不做任何事,返回 None。
"""
if await store.list():
return None
password = secrets.token_urlsafe(12)
await store.create("admin", password, role="admin", must_change_password=True)
logger.warning("已创建默认管理员,请立即登录并修改密码", username="admin", password=password)
return password