merge: integrate latest qyqy_develop (auth login + RBAC read) into ZSY branch
Incremental merge on top ofef701c8, which already integrated the earlier qyqy basebbf623a. qyqy_develop only added commits on top ofbbf623a, so this merge is conflict-free. Incoming: account/password login (POST /api/v1/auth/tokens), RBAC read-only query API, rate limit dependency, login test console and user management tools. Additive changes in app/main.py, requirements.txt and pyproject.toml from both sides are all preserved. ZSY side capabilities (visitor tokens, customer service agent, knowledge retrieval, profile projection) are unchanged.
This commit is contained in:
@@ -0,0 +1,49 @@
|
||||
"""登录接口:账号密码换访问令牌。
|
||||
|
||||
`docs/05` §11 把 JWT 的签发划给统一身份认证模块,本路由只做**登录**这一步,
|
||||
刷新与注销留待后续(`app/core/security.py` 的 `RevocationStore` 协议已经留好)。
|
||||
|
||||
三点与其它接口不同的地方,都是有意为之:
|
||||
|
||||
1. **不依赖 `build_request_context`** —— 登录时本来就还没有身份,要求带令牌就成了
|
||||
"要登录先登录"。追踪标识改从 `X-Trace-ID` 请求头取,与 `auth.py:38` 的取法一致。
|
||||
2. **挂在 `enforce_rate_limit` 上** —— 这是全平台最需要限流的端点(密码爆破的入口)。
|
||||
3. **不写 `Audit` 之外的东西、也不回显失败原因** —— 失败一律 401,消息由
|
||||
`AuthService` 统一给出,见那里的模块文档第 1 条。
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.dependencies.database import get_session
|
||||
from app.api.dependencies.rate_limit import enforce_login_rate_limit
|
||||
from app.api.schemas.auth import LoginRequest
|
||||
from app.service.auth_service import AuthService
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/api/v1/auth",
|
||||
tags=["auth"],
|
||||
# 用 `enforce_login_rate_limit` 而不是通用的 `enforce_rate_limit`:后者声明依赖
|
||||
# `build_request_context`,挂在这里就成了"要登录先登录"。详见该函数的文档字符串。
|
||||
dependencies=[Depends(enforce_login_rate_limit)],
|
||||
)
|
||||
|
||||
|
||||
@router.post("/tokens")
|
||||
async def create_access_token(
|
||||
payload: LoginRequest,
|
||||
request: Request,
|
||||
session: AsyncSession = Depends(get_session), # noqa: B008
|
||||
) -> dict[str, object]:
|
||||
"""用工号/账号与密码换一个访问令牌。
|
||||
|
||||
成功响应是 `docs/05` §3.3 的信封:业务字段全在 `data` 里,`meta` 只有 `trace_id`。
|
||||
`data.roles` 与 `data.data_scope` 是**给前端决定进哪个界面用的**——
|
||||
真正的鉴权每次请求都由 `IdentityService` 查库解析,不看这里。
|
||||
"""
|
||||
# 认证失败时请求上下文尚未建立,请求头是唯一可复用的追踪标识(同 `auth.py:38`)。
|
||||
trace_id = request.headers.get("X-Trace-ID") or ""
|
||||
data = await AuthService(session).login(
|
||||
payload.username, payload.password, trace_id=trace_id
|
||||
)
|
||||
return {"data": data, "meta": {"trace_id": trace_id}}
|
||||
@@ -0,0 +1,84 @@
|
||||
"""角色与权限的只读接口(B1:先让管理员看得见)。
|
||||
|
||||
前缀与 `admin.py` 相同(`/api/v1/admin`),但**独立成文件**:`admin.py` 是表驱动的
|
||||
配置面 CRUD 工厂(`RESOURCES` 单表增删改),而角色/权限是**多对多关系查询**,
|
||||
塞进那个工厂既不自然、也会让"配置管理"和"身份管理"两件事混在一个文件里。
|
||||
|
||||
四个接口全部只读,且**不写审计**——它们返回的就是审计材料本身("谁能访问什么",
|
||||
合规检查要看的正是这份清单)。`docs/05` §19 登记时"审计"列标"否"。
|
||||
|
||||
所有响应都走 `docs/05` §3.3 的统一信封(复用 `app/api/views/envelope.py`):
|
||||
列表的 `data` 是纯数组、分页元数据在 `meta`;单对象的 `meta` 只有 `trace_id`。
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, Path
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.dependencies.auth import build_request_context
|
||||
from app.api.dependencies.database import get_session
|
||||
from app.api.dependencies.rate_limit import enforce_rate_limit
|
||||
from app.api.views.envelope import envelope, list_envelope
|
||||
from app.core.contracts import RequestContext
|
||||
from app.service.rbac_query_service import RbacQueryService
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/api/v1/admin",
|
||||
tags=["platform-admin"],
|
||||
dependencies=[Depends(enforce_rate_limit)],
|
||||
)
|
||||
|
||||
#: 角色码在 `sys_role` 里形如 `customer` / `risk_operator` / `admin`。
|
||||
ROLE_CODE = Path(min_length=1, max_length=64, pattern=r"^[a-z0-9_]+$")
|
||||
#: `sys_user.id` 是 BIGINT UNSIGNED。
|
||||
USER_ID = Path(pattern=r"^[0-9]{1,20}$")
|
||||
|
||||
|
||||
@router.get("/roles")
|
||||
async def list_roles(
|
||||
context: RequestContext = Depends(build_request_context), # noqa: B008
|
||||
session: AsyncSession = Depends(get_session), # noqa: B008
|
||||
) -> dict[str, object]:
|
||||
"""所有角色 + 每个角色的权限数与在用人数。"""
|
||||
page = await RbacQueryService(session).list_roles(context)
|
||||
return list_envelope(page, context)
|
||||
|
||||
|
||||
@router.get("/roles/{role_code}")
|
||||
async def get_role(
|
||||
role_code: str = ROLE_CODE,
|
||||
context: RequestContext = Depends(build_request_context), # noqa: B008
|
||||
session: AsyncSession = Depends(get_session), # noqa: B008
|
||||
) -> dict[str, object]:
|
||||
"""单个角色的详情。
|
||||
|
||||
与 `/permissions` 分开是必要的:权限为空的角色(例如刚建好还没授权的)在一个合并
|
||||
接口里很容易被"查不到权限"误判成"角色不存在"。
|
||||
"""
|
||||
data = await RbacQueryService(session).get_role_detail(context, role_code)
|
||||
return envelope(data, context)
|
||||
|
||||
|
||||
@router.get("/roles/{role_code}/permissions")
|
||||
async def list_role_permissions(
|
||||
role_code: str = ROLE_CODE,
|
||||
context: RequestContext = Depends(build_request_context), # noqa: B008
|
||||
session: AsyncSession = Depends(get_session), # noqa: B008
|
||||
) -> dict[str, object]:
|
||||
"""该角色的权限清单,按权限码排序,便于与代码里各 Service 的 `require(...)` 对照。"""
|
||||
page = await RbacQueryService(session).list_role_permissions(context, role_code)
|
||||
return list_envelope(page, context)
|
||||
|
||||
|
||||
@router.get("/users/{user_id}/roles")
|
||||
async def get_user_roles(
|
||||
user_id: str = USER_ID,
|
||||
context: RequestContext = Depends(build_request_context), # noqa: B008
|
||||
session: AsyncSession = Depends(get_session), # noqa: B008
|
||||
) -> dict[str, object]:
|
||||
"""某个用户**实际解析出来**的角色 / 权限 / 数据范围 / 可见客户。
|
||||
|
||||
这是四个接口里最有用的一个:它直接回答"这个人为什么 403"——
|
||||
走的是 `IdentityService.resolve`,与请求期完全同一条链路。
|
||||
"""
|
||||
data = await RbacQueryService(session).get_user_identity(context, user_id)
|
||||
return envelope(data, context)
|
||||
@@ -69,3 +69,49 @@ async def enforce_rate_limit(
|
||||
f"请在 {retry_after_seconds} 秒后重试",
|
||||
retry_after_seconds,
|
||||
)
|
||||
|
||||
|
||||
#: 登录端点的限流参数。比普通接口严得多:普通接口的 `policy.max_requests` 是按"已登录用户
|
||||
#: 的操作频率"定的,而这里是**密码爆破**的入口,必须独立收紧。
|
||||
LOGIN_WINDOW_SECONDS = 60
|
||||
LOGIN_MAX_ATTEMPTS = 10
|
||||
LOGIN_COUNTER_PREFIX = "login"
|
||||
|
||||
|
||||
async def enforce_login_rate_limit(request: Request) -> None:
|
||||
"""登录端点专用的限流闸门:按客户端 IP,**不依赖认证上下文**。
|
||||
|
||||
为什么不能复用 `enforce_rate_limit`:它声明依赖 `build_request_context`
|
||||
(见本模块文档"顺序保证"),挂到登录端点就变成"要登录先登录"——登录请求本来
|
||||
就不带令牌。而爆破恰恰发生在**没有令牌**的时候,所以这里必须另立一个闸门。
|
||||
|
||||
维度取客户端 IP + 路由模板:拿不到 `RequestContext.user_id`(那时还没有身份),
|
||||
用 IP 是唯一可用的稳定维度;本地部署里所有客户端可能共用一个出口 IP,但登录
|
||||
端点的价值在于**挡住自动化爆破**,IP 维度足够,且不引入第二套鉴权解析。
|
||||
|
||||
降级与 `enforce_rate_limit` 一致:后端返回 `None`(Redis 不可用)时**放行**并告警,
|
||||
不因为限流组件故障把所有人挡在门外。
|
||||
"""
|
||||
policy = RateLimitPolicy.from_settings(get_settings())
|
||||
if not policy.enabled:
|
||||
return
|
||||
client = request.client.host if request.client is not None else "unknown"
|
||||
template = route_template(request)
|
||||
result = await get_counter_backend().increment(
|
||||
f"{LOGIN_COUNTER_PREFIX}:{client}:{request.method}:{template}",
|
||||
LOGIN_WINDOW_SECONDS,
|
||||
)
|
||||
if result is None:
|
||||
logger.warning("限流后端不可用,降级放行 route=%s", template)
|
||||
return
|
||||
count, retry_after_seconds = result
|
||||
if count > LOGIN_MAX_ATTEMPTS:
|
||||
logger.warning(
|
||||
"登录限流 route=%s ip=%s count=%s limit=%s",
|
||||
template, client, count, LOGIN_MAX_ATTEMPTS,
|
||||
)
|
||||
raise RateLimitExceededError(
|
||||
f"登录尝试过于频繁:每 {LOGIN_WINDOW_SECONDS} 秒最多 {LOGIN_MAX_ATTEMPTS} 次,"
|
||||
f"请在 {retry_after_seconds} 秒后重试",
|
||||
retry_after_seconds,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
"""账号密码登录。
|
||||
|
||||
`extra="forbid"`:多传字段直接 422,避免调用方以为可以塞 `roles`/`user_id`
|
||||
之类的越权参数(身份只能由服务端按 RBAC 解析)。
|
||||
长度上限是防滥用,不是密码策略——真正的校验在 `AuthService`。
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
username: str = Field(min_length=1, max_length=64)
|
||||
password: str = Field(min_length=1, max_length=128)
|
||||
Reference in New Issue
Block a user