Files
kplam 6c6f690788 chore: 完成全量功能迭代与部署准备
- 移除冗余依赖包
- 新增账号禁用校验与用户管理能力
- 新增文档下载与管理页面文件展示
- 新增API文档页面与用户管理前端页面
- 重构时区处理与docker-compose部署配置
- 完善测试用例与项目文档
2026-08-01 00:02:42 +08:00

232 lines
8.5 KiB
Python
Raw Permalink 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.
"""用户核心:用户记录存储与密码哈希
存储后端为 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, field
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):
"""用户存储后端读写异常"""
class UserNotFoundError(Exception):
"""用户不存在"""
@dataclass
class UserRecord:
"""用户记录(不含明文密码)"""
username: str
role: str # "admin" | "user"
password_hash: str # hex
salt: str # hex, 16 字节
must_change_password: bool
enabled: bool = True
# created_at 紧随 enabled 之后;二者均有默认值以满足 dataclass 字段顺序约束
created_at: str = field(default_factory=lambda: datetime.now(UTC).isoformat())
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,
enabled: bool = True,
) -> 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,
enabled=enabled,
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 self._from_raw(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 [self._from_raw(raw) for raw in raws if raw is not None]
@staticmethod
def _from_raw(raw: str) -> UserRecord:
"""从 JSON 反序列化 UserRecord;存量记录无 enabled 字段时按 True 兼容"""
data = json.loads(raw)
if "enabled" not in data:
data["enabled"] = True
return UserRecord(**data)
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 update_user(
self,
username: str,
*,
role: str | None = None,
enabled: bool | None = None,
) -> UserRecord:
"""更新用户角色或启用状态
用户不存在抛 UserNotFoundErrorrole 非 admin/user 抛 ValueError。
role/enabled 为 None 表示不修改对应字段。更新后写回并返回最新记录。
"""
record = await self.get(username)
if record is None:
raise UserNotFoundError(f"用户不存在: {username}")
if role is not None and role not in ("admin", "user"):
raise ValueError(f"非法角色: {role!r}(须为 admin/user")
if role is not None:
record.role = role
if enabled is not None:
record.enabled = enabled
await self._write(record)
return record
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