6c6f690788
- 移除冗余依赖包 - 新增账号禁用校验与用户管理能力 - 新增文档下载与管理页面文件展示 - 新增API文档页面与用户管理前端页面 - 重构时区处理与docker-compose部署配置 - 完善测试用例与项目文档
249 lines
9.5 KiB
Python
249 lines
9.5 KiB
Python
"""认证与用户管理 API:/api/v1/auth 下的登录/退出/改密与用户管理端点
|
||
|
||
会话制认证:登录签发 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, Header
|
||
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, UserNotFoundError, UserRecord, UserStoreError
|
||
|
||
logger = structlog.get_logger()
|
||
|
||
router = APIRouter(prefix="/api/v1/auth", tags=["auth"])
|
||
|
||
|
||
class LoginRequest(BaseModel):
|
||
"""登录请求"""
|
||
|
||
username: str
|
||
password: str
|
||
|
||
|
||
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
|
||
|
||
|
||
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,
|
||
}
|
||
|
||
|
||
@router.post("/login")
|
||
async def login(req: LoginRequest) -> dict[str, Any]:
|
||
"""用户名密码登录:成功签发 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, "用户名或密码错误")
|
||
if not user.enabled:
|
||
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(
|
||
{
|
||
"token": token,
|
||
"username": user.username,
|
||
"role": user.role,
|
||
"must_change_password": user.must_change_password,
|
||
}
|
||
)
|
||
|
||
|
||
@router.get("/me")
|
||
async def me(user: UserRecord = Depends(deps.get_current_user)) -> dict[str, Any]:
|
||
"""返回当前登录用户信息(脱敏),供管理页面校验登录态"""
|
||
return ok(_public_user(user))
|
||
|
||
|
||
@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 语义)。
|
||
"""
|
||
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("/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.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)
|
||
) -> 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})
|