diff --git a/CLAUDE.md b/CLAUDE.md index c035633..9c753a9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -103,6 +103,7 @@ QMDSearch/ | GET | `/api/v1/auth/me` | 当前登录用户信息(脱敏) | Bearer | | GET | `/api/v1/auth/users` | 用户列表(脱敏) | Bearer + admin | | POST | `/api/v1/auth/users` | 创建用户(重名/非法用户名/弱密码 1001) | Bearer + admin | +| PATCH | `/api/v1/auth/users/{username}` | 更新用户角色/启用状态(仅 admin) | Bearer + admin | | POST | `/api/v1/auth/users/{username}/password` | 重置指定用户密码(成功后清除其全部 session) | Bearer + admin | | DELETE | `/api/v1/auth/users/{username}` | 删除用户(清除其 session;禁删自己/最后一个 admin) | Bearer + admin | | GET | `/admin` | 管理页面 | 页面登录门禁 | @@ -113,16 +114,17 @@ QMDSearch/ ## 管理页面 -浏览器访问 `/admin`,页面带登录门禁(未登录/token 失效自动回登录卡片;must_change_password 用户先强制改密后方可进入)。单页面含七个区块:概览、文档管理(列表/详情/删除)、文档入库(文本 + 文件上传)、检索测试台、类目列表、API 指南(端点清单 + 在线测试台)、用户管理(仅 admin 角色挂载,含创建/重置密码/删除)。 +浏览器访问 `/admin`,页面带登录门禁(未登录/token 失效自动回登录卡片;must_change_password 用户先强制改密后方可进入)。单页面含八个区块(admin 视角;user 角色无用户管理区块):概览、个人中心(user 角色默认进入且可见,含账号信息/改密/退出)、文档管理(列表/详情/删除)、文档入库(文本 + 文件上传)、检索测试台、类目列表、API 指南(端点清单 + 在线测试台)、用户管理(仅 admin 角色挂载,含搜索筛选/行内编辑角色与启用状态/弹窗式重置密码与删除)。 ## 认证与用户 会话制认证:登录签发 session token(Redis 持久化,TTL 12h;Redis 不可用时降级为进程内存,重启失效),请求经 `Authorization: Bearer ` 携带,鉴权依赖见 `app/api/deps.py`。 - **角色与权限边界**: `admin` 拥有全部权限(含 /auth/users* 用户管理);`user` 可登录并调用变更类文档端点(入库/上传/删除),访问用户管理端点返回 1006 +- **启用状态与 PATCH 端点**: 用户记录含 `enabled` 字段(默认 true);admin 可经 `PATCH /auth/users/{username}` 更新角色或启用状态(请求体至少一项字段,空 body 1001;role 非法 1001;用户不存在 1004)。禁用用户时清除其全部 session(旧 token 立即失效 1005),被禁用用户登录拒绝 1005("账号已禁用");最后一个 admin 禁止降级 role 或被禁用(1001)。角色变更不清 session,同一 token 即时反映新角色 - **初始 admin 引导**: 空库启动时 `bootstrap_admin` 自动创建 admin 账号,随机明文密码仅在启动日志中打印一次(must_change_password=true),首次登录后须先经 POST /auth/password 改密,改密前访问其他端点返回 1006 - **免登录端点**: 查询类端点(POST /search、GET /documents*、GET /knowledge/*、GET /health)不需要 token -- **相关错误码**: 1005 未认证或凭证无效,1006 权限不足/首次登录须先改密 +- **相关错误码**: 1005 未认证或凭证无效/账号已禁用,1006 权限不足/首次登录须先改密 ## 编码规范 diff --git a/app/api/deps.py b/app/api/deps.py index 8e52e3e..57ad600 100644 --- a/app/api/deps.py +++ b/app/api/deps.py @@ -80,9 +80,12 @@ async def _resolve_user(authorization: str | None) -> tuple[UserRecord, str]: async def get_current_user(authorization: str | None = Header(None)) -> UserRecord: """鉴权依赖:校验 Bearer token 并返回当前用户记录 - must_change_password 用户被拦截(1006),须先经 POST /auth/password 改密。 + 禁用用户被拦截(1005);must_change_password 用户被拦截(1006),须先经 POST /auth/password 改密。 + 注:logout/password 端点经 _resolve_user 自解析,不在此拦截范围内(改密是已登录用户唯一可用接口)。 """ user, _ = await _resolve_user(authorization) + if not user.enabled: + raise ApiError(1005, "账号已禁用") if user.must_change_password: raise ApiError(1006, "首次登录须先修改密码") return user diff --git a/app/api/v1/auth.py b/app/api/v1/auth.py index 31700ca..1c0746e 100644 --- a/app/api/v1/auth.py +++ b/app/api/v1/auth.py @@ -10,12 +10,12 @@ from typing import Any, Literal import structlog from fastapi import APIRouter, Depends, Header -from pydantic import BaseModel +from pydantic import BaseModel, model_validator from app.api import deps from app.api.response import ApiError, ok from app.core.sessions import SessionStoreError -from app.core.users import UserExistsError, UserRecord, UserStoreError +from app.core.users import UserExistsError, UserNotFoundError, UserRecord, UserStoreError logger = structlog.get_logger() @@ -50,12 +50,26 @@ class PasswordResetRequest(BaseModel): new_password: str +class UserUpdateRequest(BaseModel): + """管理员更新用户角色/启用状态(至少一项)""" + + role: Literal["admin", "user"] | None = None + enabled: bool | None = None + + @model_validator(mode="after") + def _at_least_one(self) -> "UserUpdateRequest": + if self.role is None and self.enabled is None: + raise ValueError("至少需要提供一项更新字段(role 或 enabled)") + return self + + 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, + "enabled": user.enabled, "created_at": user.created_at, } @@ -69,6 +83,8 @@ async def login(req: LoginRequest) -> dict[str, Any]: raise ApiError(2001, "认证服务暂不可用") from exc if user is None: raise ApiError(1005, "用户名或密码错误") + if not user.enabled: + raise ApiError(1005, "账号已禁用") try: token = await deps._get_session_store().create(user.username, user.role) except SessionStoreError as exc: @@ -151,6 +167,46 @@ async def create_user( return ok(_public_user(user)) +@router.patch("/users/{username}") +async def update_user( + username: str, + req: UserUpdateRequest, + admin: UserRecord = Depends(deps.require_admin), +) -> dict[str, Any]: + """更新用户角色或启用状态 + + 用户不存在 1004;role 非法 1001;最后一个 admin 降级 role 或被禁用 1001; + 禁用用户时清除其全部 session(已登录态立即失效)。 + """ + store = deps._get_user_store() + try: + target = await store.get(username) + if target is None: + raise ApiError(1004, "用户不存在") + # 最后 admin 保护:目标当前是唯一 admin 且本次会使其失去 admin 身份或被禁用 + if target.role == "admin" and await store.count_admins() <= 1: + will_lose_admin = (req.role is not None and req.role != "admin") or (req.enabled is False) + if will_lose_admin: + raise ApiError(1001, "禁止降级或禁用最后一个管理员") + updated = await store.update_user(username, role=req.role, enabled=req.enabled) + except UserStoreError as exc: + raise ApiError(2001, "认证服务暂不可用") from exc + except UserNotFoundError as exc: + raise ApiError(1004, "用户不存在") from exc + except ValueError as exc: + raise ApiError(1001, str(exc)) from exc + # 禁用用户 → 清除其全部 session(已登录态立即失效) + if req.enabled is False: + try: + await deps._get_session_store().delete_by_username(username) + except SessionStoreError as exc: + raise ApiError(2001, "认证服务暂不可用") from exc + logger.info( + "管理员更新用户", username=username, operator=admin.username, role=req.role, enabled=req.enabled + ) + return ok(_public_user(updated)) + + @router.post("/users/{username}/password") async def reset_user_password( username: str, req: PasswordResetRequest, admin: UserRecord = Depends(deps.require_admin) diff --git a/app/core/auth.py b/app/core/auth.py index a6a63c0..8dc1188 100644 --- a/app/core/auth.py +++ b/app/core/auth.py @@ -4,7 +4,7 @@ 鉴权(get_current_user)以 JWT 自包含信息为主,Redis 不可用时降级可用。 """ -from datetime import UTC, datetime, timedelta +from datetime import datetime, timedelta, timezone import bcrypt import jwt @@ -67,7 +67,7 @@ def verify_password(password: str, hashed: str) -> bool: def create_access_token(username: str, role: str) -> tuple[str, int]: """签发 JWT,返回 (token, expires_in_seconds)""" - now = datetime.now(UTC) + now = datetime.now(timezone.utc) expire = now + timedelta(minutes=settings.jwt_expire_minutes) payload = { "sub": username, @@ -136,7 +136,7 @@ class UserStore: user = StoredUser( username=username, role=role, - created_at=datetime.now(UTC), + created_at=datetime.now(timezone.utc), hashed_password=hash_password(password), ) try: @@ -203,7 +203,7 @@ async def get_current_user( ) # Redis 不可用或用户不存在(可能已删除):降级用 JWT payload logger.warning("用户存储查询未命中,降级使用 JWT payload", username=username) - return AuthUser(username=username, role=role, created_at=datetime.now(UTC)) + return AuthUser(username=username, role=role, created_at=datetime.now(timezone.utc)) async def require_admin(user: AuthUser = Depends(get_current_user)) -> AuthUser: diff --git a/app/core/users.py b/app/core/users.py index 4983700..ab5e7ad 100644 --- a/app/core/users.py +++ b/app/core/users.py @@ -11,7 +11,7 @@ import hmac import json import re import secrets -from dataclasses import asdict, dataclass +from dataclasses import asdict, dataclass, field from datetime import UTC, datetime from redis import asyncio as redis_async @@ -41,6 +41,10 @@ class UserStoreError(Exception): """用户存储后端读写异常""" +class UserNotFoundError(Exception): + """用户不存在""" + + @dataclass class UserRecord: """用户记录(不含明文密码)""" @@ -50,7 +54,9 @@ class UserRecord: password_hash: str # hex salt: str # hex, 16 字节 must_change_password: bool - created_at: str # UTC ISO8601 + enabled: bool = True + # created_at 紧随 enabled 之后;二者均有默认值以满足 dataclass 字段顺序约束 + created_at: str = field(default_factory=lambda: datetime.now(UTC).isoformat()) class UserStore: @@ -76,6 +82,7 @@ class UserStore: password: str, role: str = "user", must_change_password: bool = False, + enabled: bool = True, ) -> UserRecord: """创建用户 @@ -94,6 +101,7 @@ class UserStore: 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) @@ -104,7 +112,7 @@ class UserStore: raw = await self._read_raw(username) if raw is None: return None - return UserRecord(**json.loads(raw)) + return self._from_raw(raw) async def list(self) -> list[UserRecord]: """列出全部用户(扫描 user:* 键)""" @@ -115,7 +123,15 @@ class UserStore: 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] + 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)""" @@ -158,6 +174,30 @@ class UserStore: """统计 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) diff --git a/app/static/admin.html b/app/static/admin.html index 3845048..4e25161 100644 --- a/app/static/admin.html +++ b/app/static/admin.html @@ -107,6 +107,10 @@ .tag { display: inline-block; background: #eff6ff; color: #1d4ed8; border-radius: 3px; padding: 1px 6px; font-size: 12px; margin-right: 4px; } .fallback-flag { color: #b45309; background: #fffbeb; border: 1px solid #fcd34d; border-radius: 4px; padding: 6px 10px; font-size: 13px; margin-bottom: 10px; } .toolbar { margin: 12px 0; } + .toolbar.users-toolbar { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; } + .users-toolbar input[type="text"], .users-toolbar select { width: auto; } + .users-toolbar input[type="text"] { min-width: 200px; } + .user-role-select { width: auto; display: inline-block; margin-right: 4px; } .muted { color: #6b7280; font-size: 12px; } .status-badge { display: inline-block; border-radius: 3px; padding: 1px 8px; @@ -133,6 +137,20 @@ box-shadow: 0 6px 20px rgba(0,0,0,0.25); } .login-box h2 { font-size: 16px; margin: 0 0 16px; } + /* 通用 overlay 弹窗组件:固定全屏遮罩 + 居中卡片 */ + .modal-overlay { + position: fixed; inset: 0; background: rgba(0,0,0,0.45); + display: flex; align-items: center; justify-content: center; z-index: 200; + } + .modal-card { + background: #fff; border-radius: 8px; padding: 20px 24px; width: 380px; + max-width: calc(100vw - 32px); box-shadow: 0 6px 20px rgba(0,0,0,0.25); + } + .modal-card .modal-title { font-size: 16px; margin: 0 0 14px; } + .modal-card .modal-content { font-size: 13px; } + .modal-card .modal-actions { margin-top: 14px; display: flex; gap: 8px; justify-content: flex-end; } + .modal-card .modal-strength-hint { margin-top: -6px; margin-bottom: 12px; font-size: 12px; } + .modal-card .modal-confirm-text { margin-bottom: 10px; } .extracted-info { background: #f0f9ff; border: 1px solid #bae6fd; border-radius: 6px; padding: 10px 12px; margin-bottom: 12px; font-size: 13px; @@ -227,6 +245,8 @@ + +

知识库管理后台

@@ -256,6 +277,32 @@
+ +