6c6f690788
- 移除冗余依赖包 - 新增账号禁用校验与用户管理能力 - 新增文档下载与管理页面文件展示 - 新增API文档页面与用户管理前端页面 - 重构时区处理与docker-compose部署配置 - 完善测试用例与项目文档
232 lines
8.5 KiB
Python
232 lines
8.5 KiB
Python
"""用户核心:用户记录存储与密码哈希
|
||
|
||
存储后端为 Redis(key: user:{username},JSON 序列化,无 TTL 持久化);
|
||
构造时传入 None 降级为进程级内存 dict(重启丢失,仅用于测试与 Redis 故障兜底)。
|
||
所有 Redis 读写异常统一包装为 UserStoreError 抛出(不静默),由上层转 2001。
|
||
密码哈希使用标准库 hashlib.pbkdf2_hmac(SHA256,100_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-SHA256(100_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:
|
||
"""更新用户角色或启用状态
|
||
|
||
用户不存在抛 UserNotFoundError;role 非 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
|