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,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
|
||||
+172
-42
@@ -1,62 +1,192 @@
|
||||
"""认证 API:POST /auth/login、POST /auth/register、GET /auth/me"""
|
||||
"""认证与用户管理 API:/api/v1/auth 下的登录/退出/改密与用户管理端点
|
||||
|
||||
from typing import Any
|
||||
会话制认证:登录签发 session token(存储见 app/core/sessions.py,TTL 12h),
|
||||
请求经 Authorization: Bearer <token> 携带,鉴权依赖见 app/api/deps.py。
|
||||
用户管理端点(/users*)全部要求 admin 角色;/logout 与 /password 对
|
||||
must_change_password 用户放行(改密是被拦截用户唯一能用的接口)。
|
||||
"""
|
||||
|
||||
from typing import Any, Literal
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi import APIRouter, Depends, Header
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.api import deps
|
||||
from app.api.response import ApiError, ok
|
||||
from app.config import settings
|
||||
from app.core.auth import (
|
||||
ERR_REGISTER_DISABLED,
|
||||
UserStore,
|
||||
create_access_token,
|
||||
get_current_user,
|
||||
get_user_store,
|
||||
)
|
||||
from app.models.auth import (
|
||||
AuthUser,
|
||||
LoginRequest,
|
||||
RegisterRequest,
|
||||
TokenResponse,
|
||||
)
|
||||
from app.core.sessions import SessionStoreError
|
||||
from app.core.users import UserExistsError, UserRecord, UserStoreError
|
||||
|
||||
logger = structlog.get_logger()
|
||||
|
||||
router = APIRouter(prefix="/api/v1", tags=["auth"])
|
||||
router = APIRouter(prefix="/api/v1/auth", tags=["auth"])
|
||||
|
||||
|
||||
def _build_token_response(user, store: UserStore) -> dict[str, Any]:
|
||||
"""签发 token 并构造统一响应 data"""
|
||||
token, expires_in = create_access_token(user.username, user.role)
|
||||
auth_user = AuthUser(username=user.username, role=user.role, created_at=user.created_at)
|
||||
resp = TokenResponse(access_token=token, expires_in=expires_in, user=auth_user)
|
||||
return resp.model_dump(mode="json")
|
||||
class LoginRequest(BaseModel):
|
||||
"""登录请求"""
|
||||
|
||||
username: str
|
||||
password: str
|
||||
|
||||
|
||||
@router.post("/auth/login")
|
||||
class PasswordChangeRequest(BaseModel):
|
||||
"""修改自己的密码"""
|
||||
|
||||
old_password: str
|
||||
new_password: str
|
||||
|
||||
|
||||
class UserCreateRequest(BaseModel):
|
||||
"""管理员创建用户"""
|
||||
|
||||
username: str
|
||||
password: str
|
||||
role: Literal["admin", "user"] = "user"
|
||||
|
||||
|
||||
class PasswordResetRequest(BaseModel):
|
||||
"""管理员重置他人密码"""
|
||||
|
||||
new_password: str
|
||||
|
||||
|
||||
def _public_user(user: UserRecord) -> dict[str, Any]:
|
||||
"""用户记录脱敏:剔除 password_hash/salt"""
|
||||
return {
|
||||
"username": user.username,
|
||||
"role": user.role,
|
||||
"must_change_password": user.must_change_password,
|
||||
"created_at": user.created_at,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/login")
|
||||
async def login(req: LoginRequest) -> dict[str, Any]:
|
||||
"""用户名密码登录,返回 JWT access_token"""
|
||||
store = get_user_store()
|
||||
user = await store.authenticate(req.username, req.password)
|
||||
"""用户名密码登录:成功签发 session token(must_change_password 用户也可登录)"""
|
||||
try:
|
||||
user = await deps._get_user_store().verify_password(req.username, req.password)
|
||||
except UserStoreError as exc:
|
||||
raise ApiError(2001, "认证服务暂不可用") from exc
|
||||
if user is None:
|
||||
raise ApiError(1005, "用户名或密码错误")
|
||||
try:
|
||||
token = await deps._get_session_store().create(user.username, user.role)
|
||||
except SessionStoreError as exc:
|
||||
raise ApiError(2001, "认证服务暂不可用") from exc
|
||||
logger.info("用户登录成功", username=user.username)
|
||||
return ok(_build_token_response(user, store))
|
||||
return ok(
|
||||
{
|
||||
"token": token,
|
||||
"username": user.username,
|
||||
"role": user.role,
|
||||
"must_change_password": user.must_change_password,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.post("/auth/register")
|
||||
async def register(req: RegisterRequest) -> dict[str, Any]:
|
||||
"""注册新用户(role=user),注册后自动签发 token
|
||||
@router.get("/me")
|
||||
async def me(user: UserRecord = Depends(deps.get_current_user)) -> dict[str, Any]:
|
||||
"""返回当前登录用户信息(脱敏),供管理页面校验登录态"""
|
||||
return ok(_public_user(user))
|
||||
|
||||
受 settings.auth_register_enabled 控制,关闭时返回 ERR_REGISTER_DISABLED。
|
||||
|
||||
@router.post("/logout")
|
||||
async def logout(authorization: str | None = Header(None)) -> dict[str, Any]:
|
||||
"""退出登录:删除当前 session(自解析 Bearer,不拦截 must_change_password)"""
|
||||
_, token = await deps._resolve_user(authorization)
|
||||
try:
|
||||
await deps._get_session_store().delete(token)
|
||||
except SessionStoreError as exc:
|
||||
raise ApiError(2001, "认证服务暂不可用") from exc
|
||||
return ok({"logged_out": True})
|
||||
|
||||
|
||||
@router.post("/password")
|
||||
async def change_password(
|
||||
req: PasswordChangeRequest, authorization: str | None = Header(None)
|
||||
) -> dict[str, Any]:
|
||||
"""修改自己的密码(自解析 Bearer,不拦截 must_change_password——这是被拦截用户唯一可用接口)
|
||||
|
||||
旧密码错误 1005;新密码不足 8 位 1001。改密不清除既有 session(Task1 语义)。
|
||||
"""
|
||||
if not settings.auth_register_enabled:
|
||||
raise ApiError(ERR_REGISTER_DISABLED, "注册已关闭")
|
||||
store = get_user_store()
|
||||
user = await store.create(req.username, req.password, role="user")
|
||||
return ok(_build_token_response(user, store))
|
||||
user, _ = await deps._resolve_user(authorization)
|
||||
store = deps._get_user_store()
|
||||
try:
|
||||
verified = await store.verify_password(user.username, req.old_password)
|
||||
if verified is None:
|
||||
raise ApiError(1005, "旧密码错误")
|
||||
await store.set_password(user.username, req.new_password)
|
||||
except (UserStoreError, SessionStoreError) as exc:
|
||||
raise ApiError(2001, "认证服务暂不可用") from exc
|
||||
except ValueError as exc:
|
||||
raise ApiError(1001, str(exc)) from exc
|
||||
logger.info("用户修改密码", username=user.username)
|
||||
return ok({"username": user.username})
|
||||
|
||||
|
||||
@router.get("/auth/me")
|
||||
async def me(user: AuthUser = Depends(get_current_user)) -> dict[str, Any]:
|
||||
"""返回当前登录用户信息"""
|
||||
return ok(user.model_dump(mode="json"))
|
||||
@router.get("/users")
|
||||
async def list_users(admin: UserRecord = Depends(deps.require_admin)) -> dict[str, Any]:
|
||||
"""列出全部用户(脱敏:不含 password_hash/salt)"""
|
||||
try:
|
||||
users = await deps._get_user_store().list()
|
||||
except UserStoreError as exc:
|
||||
raise ApiError(2001, "认证服务暂不可用") from exc
|
||||
return ok([_public_user(user) for user in users])
|
||||
|
||||
|
||||
@router.post("/users")
|
||||
async def create_user(
|
||||
req: UserCreateRequest, admin: UserRecord = Depends(deps.require_admin)
|
||||
) -> dict[str, Any]:
|
||||
"""创建用户:重名/非法用户名/弱密码 1001;成功返回脱敏后的创建记录"""
|
||||
try:
|
||||
user = await deps._get_user_store().create(req.username, req.password, role=req.role)
|
||||
except UserExistsError as exc:
|
||||
raise ApiError(1001, str(exc)) from exc
|
||||
except ValueError as exc:
|
||||
raise ApiError(1001, str(exc)) from exc
|
||||
except UserStoreError as exc:
|
||||
raise ApiError(2001, "认证服务暂不可用") from exc
|
||||
logger.info("管理员创建用户", username=user.username, role=user.role, operator=admin.username)
|
||||
return ok(_public_user(user))
|
||||
|
||||
|
||||
@router.post("/users/{username}/password")
|
||||
async def reset_user_password(
|
||||
username: str, req: PasswordResetRequest, admin: UserRecord = Depends(deps.require_admin)
|
||||
) -> dict[str, Any]:
|
||||
"""重置指定用户密码:用户不存在 1004;成功后清除该用户全部 session"""
|
||||
store = deps._get_user_store()
|
||||
try:
|
||||
if await store.get(username) is None:
|
||||
raise ApiError(1004, "用户不存在")
|
||||
await store.set_password(username, req.new_password)
|
||||
await deps._get_session_store().delete_by_username(username)
|
||||
except (UserStoreError, SessionStoreError) as exc:
|
||||
raise ApiError(2001, "认证服务暂不可用") from exc
|
||||
except ValueError as exc:
|
||||
raise ApiError(1001, str(exc)) from exc
|
||||
logger.info("管理员重置用户密码", username=username, operator=admin.username)
|
||||
return ok({"username": username})
|
||||
|
||||
|
||||
@router.delete("/users/{username}")
|
||||
async def delete_user(
|
||||
username: str, admin: UserRecord = Depends(deps.require_admin)
|
||||
) -> dict[str, Any]:
|
||||
"""删除用户:不存在 1004;删除自己 1001;最后一个 admin 禁止删除 1001;成功后清除其 session"""
|
||||
store = deps._get_user_store()
|
||||
try:
|
||||
target = await store.get(username)
|
||||
if target is None:
|
||||
raise ApiError(1004, "用户不存在")
|
||||
if target.username == admin.username:
|
||||
raise ApiError(1001, "不能删除当前登录账号")
|
||||
if target.role == "admin" and await store.count_admins() <= 1:
|
||||
raise ApiError(1001, "禁止删除最后一个管理员")
|
||||
await store.delete(username)
|
||||
await deps._get_session_store().delete_by_username(username)
|
||||
except (UserStoreError, SessionStoreError) as exc:
|
||||
raise ApiError(2001, "认证服务暂不可用") from exc
|
||||
logger.info("管理员删除用户", username=username, operator=admin.username)
|
||||
return ok({"username": username})
|
||||
|
||||
+7
-11
@@ -10,12 +10,13 @@ import structlog
|
||||
from fastapi import APIRouter, Depends, File, Form, Query, UploadFile
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from app.api.deps import get_current_user
|
||||
from app.api.response import ApiError, ok
|
||||
from app.config import Settings, settings
|
||||
from app.core.auth import AuthUser, get_current_user, require_admin
|
||||
from app.core.file_parser import parse_file
|
||||
from app.core.ingest_tasks import IngestTaskManager
|
||||
from app.core.ingestion import Ingester
|
||||
from app.core.users import UserRecord
|
||||
from app.models.document import DocumentInput
|
||||
from app.services.qdrant import QdrantService
|
||||
from app.services.redis import RedisCache, get_cache
|
||||
@@ -74,7 +75,7 @@ def _allowed_extensions() -> set[str]:
|
||||
|
||||
@router.post("/documents")
|
||||
async def ingest_document(
|
||||
doc: DocumentInput, user: AuthUser = Depends(get_current_user)
|
||||
doc: DocumentInput, user: UserRecord = Depends(get_current_user)
|
||||
) -> JSONResponse:
|
||||
"""文档入库入口:登记异步任务并返回 202 + task_id,入库结果经任务查询端点获取"""
|
||||
if not doc.text.strip():
|
||||
@@ -95,7 +96,7 @@ async def upload_document(
|
||||
metadata: str = Form(
|
||||
default="", description='可选元数据 JSON 字符串,如 \'{"author":"x"}\''
|
||||
),
|
||||
user: AuthUser = Depends(get_current_user),
|
||||
user: UserRecord = Depends(get_current_user),
|
||||
) -> JSONResponse:
|
||||
"""文件上传入库入口:校验 → 提取文本 → 落盘 → 提交异步入库流水线
|
||||
|
||||
@@ -183,9 +184,7 @@ async def upload_document(
|
||||
|
||||
|
||||
@router.get("/documents/tasks/{task_id}")
|
||||
async def get_ingest_task(
|
||||
task_id: str, user: AuthUser = Depends(get_current_user)
|
||||
) -> dict[str, Any]:
|
||||
async def get_ingest_task(task_id: str) -> dict[str, Any]:
|
||||
"""查询入库任务状态:含 task_id/status/created_at/updated_at,done 附 result,failed 附 error"""
|
||||
task = await _get_task_manager().get(task_id)
|
||||
if task is None:
|
||||
@@ -197,7 +196,6 @@ async def get_ingest_task(
|
||||
async def list_documents(
|
||||
limit: int = Query(default=20, ge=1, le=100),
|
||||
offset: str | None = None,
|
||||
user: AuthUser = Depends(get_current_user),
|
||||
) -> dict[str, Any]:
|
||||
"""分页列出文档(L1 摘要),返回 items 与下一页游标 next_offset"""
|
||||
try:
|
||||
@@ -209,9 +207,7 @@ async def list_documents(
|
||||
|
||||
|
||||
@router.get("/documents/{doc_id}")
|
||||
async def get_document(
|
||||
doc_id: str, user: AuthUser = Depends(get_current_user)
|
||||
) -> dict[str, Any]:
|
||||
async def get_document(doc_id: str) -> dict[str, Any]:
|
||||
"""获取文档详情:L1 记录 + L2/L3 节点 + chunks 数量"""
|
||||
try:
|
||||
detail = await _get_qdrant().get_doc_detail(doc_id)
|
||||
@@ -225,7 +221,7 @@ async def get_document(
|
||||
|
||||
@router.delete("/documents/{doc_id}")
|
||||
async def delete_document(
|
||||
doc_id: str, user: AuthUser = Depends(require_admin)
|
||||
doc_id: str, user: UserRecord = Depends(get_current_user)
|
||||
) -> dict[str, Any]:
|
||||
"""删除文档:四层集合中该 doc_id 的所有点;幂等,不存在也返回成功(删除数全 0)"""
|
||||
try:
|
||||
|
||||
Reference in New Issue
Block a user