Files
group_fqcd_jr/app/service/auth_service.py
张胜宇 5d0becb67d 客服 Agent 重构收口:五出口决策链 + 知识库档位隔离 + 前端入参边界(答辩演示版本)
一、客服 Agent 智能增强(正面回应"不智能、动不动就转人工")
- 决策链由 2 个出口扩到 5 个:E1 澄清 / E2 计算型 / E3 知识直返 / E4 证据约束生成 / E5 分级回退
- 转人工从"默认动作"降为最后一档 E5c,只保留 4 类白名单:
  P0 反诈 / P1 账户与个人数据 / P2 写操作与争议 / 用户明确要求人工
- 46 条金标实测(修复前 → 修复后):
  转人工率 43.5% → 10.9%;出口准确率 45.7% → 100%;事实正确率 69.6% → 100%
  禁忌违反 1 → 0;档位越权 / 无出处数字 / 误拒 四项零容忍全 0
- 安全不变量 INV-1~INV-5;零容忍规则未删,改的是挂载点
  (输出侧字面黑名单 → 检索层档位隔离 + 判定层合规词表 + 输出守护)

二、知识库:档位单点化与物理隔离
- 新增 app/core/knowledge_tier.py 作为档位规则唯一落点(G-03),
  knowledge_contracts.py 原定义块改为显式再导出(X as X,非副本)
- 档位过滤由 bool 默认值(fail-open)改为 tiers 必填集合(缺参即 TypeError)
- Milvus 侧四集合按 visibility 分区键物理隔离;双 schema 收敛为一套
- 新增 app/core/actor.py:访客三元组与匿名判定的唯一构造/判定点(G-01/G-01b)
- 新增 app/core/fund_fee_rules.py:费率计算纯函数

三、前端入参边界对齐(本轮 W11 新修,4 处"校验宽于存储")
- message 加 max_length=8000(与浮窗 widget.js 的 maxlength 一致)
- session_id 加 1—64;idempotency_key 上限 128 → 64(对齐列宽 String(64))
- feedback_type 加 max_length=32(对齐列宽 String(32))
- 8 条路径参数补 min_length=1 + max_length=64 + 字符集正则
  ({session_id} / {run_id} / {handover_id})
- 改前超限值会落到 MySQL 才失败(500);改后一律 422 AGENT_INPUT_INVALID + 字段级定位
- 新增 tests/unit/api/test_frontend_boundaries.py(33 例),含"端点表 ↔ OpenAPI 全量对照"

四、投顾模块整体清除(D4.4 / D4.5)
- 删除投顾相关 controller / schema / model / repository / service 及门户页面
- tools/portal_api_check.py 同步作废 AD003/AD005/AD011/A047 四条用例与 advisor_t 登录
  (端点与账号均已不存在,此前稳定报 3 条假红)

五、验证(提交前实测)
- pytest -q:1856 passed / 2 skipped / 0 failed
- ruff check app tools tests:19(= 基线);mypy app:2(= 基线)
- 前端接口契约体检 portal_api_check.py:38 项,通过 34,失败 0,跳过 4
- 全链路冒烟 e2e_smoke_test.py --read-only:31/31
- HTTP 全链路探针 http_probe.py:11/11 succeeded
- 跨文档一致性 _consistency.py:GATE PASS
- 真机边界复验 12 条:12/12 符合预期

六、纪律与文档
- 可改文件白名单 A-09(docs/46)与底座会签申请单 A-10(docs/47,组 1—组 4 全部受理)
- 零 DDL:未新增/修改任何表结构,89 张业务表与基线一致
- 证据留痕:docs/evidence/**(含 46 条金标 score、快照、清除与重建记录)
- 未提交(刻意排除,见提交说明):仓库内 客服agent/ 与 开发文档/ 是 2026-09-16 前的
  过期副本(Todolist 440 行 vs 权威 D2.1 1167 行),权威正本在仓库外;
  _chunks_report.txt 是 tools/build_knowledge_chunks.py 生成的本地产物
2026-09-20 14:33:30 +08:00

243 lines
10 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""账号密码登录:校验密码、签发访问令牌、留痕审计。
## 为什么放在平台侧
认证是**平台级能力**:所有业务域共用同一套 RBAC(`sys_user_role` → `sys_role_permission`
→ `sys_permission`),`docs/05` §11 把"JWT 签发、刷新、注销"划给统一身份认证模块。
本模块只做**登录这一步**(账号密码换令牌);刷新与注销留给后续迭代 ——
`app/core/security.py` 已经留好 `RevocationStore` 协议,接上 Redis 即可。
放在 Service 层而不是业务 Agent 里,是因为它不属于任何一个业务域:让业务分支自己加登录
路由,等于又开一条绕过公共鉴权的路径(`AGENTS.md` 规则 7)。
## 令牌里为什么只放 `sub`
`JwtAuthenticator.authenticate` 只从令牌取 `sub`(用户 id),角色/权限/数据范围由
`IdentityService.resolve` **每次请求查库**解析(`identity_repository.load_context`:
`Fresh RBAC reads make revocation immediate`)。这是有意设计——权限变更立即生效、不受
令牌有效期拖累。所以登录只要签一个含 `sub` 的 JWT,**现有鉴权链路一行都不用改**。
三个角色的区分(客户 / 员工 / 管理员)因此已经完备:`bootstrap.py` 里各 Agent 的
`allowed_roles` 早就分开了(面向客户的 Agent 只要 `customer`、`RiskAgent` 要
`risk_operator`/`admin`、`PlatformProbeAgent` 只要 `admin`),此前唯独缺"怎么证明你是谁"。
## 安全约定(金融场景,逐条对应下面的实现)
1. **不区分失败原因**。用户不存在、密码错、账号停用、密码未初始化 —— 对外**同一条** 401
消息。`docs/05` §3.6 只给了一个 `AUTHENTICATION_REQUIRED`,客户端本来也不该据 message
区分。否则这个接口就成了账号枚举器。
2. **防时序枚举**。用户不存在时**照样跑一次 bcrypt 比对**(`_DUMMY_HASH`)。否则
"查无此人"会明显快于"密码错",同样能枚举出哪些账号存在。
3. **成功与失败都审计**。金融场景必须能回答"谁、什么时候、从哪、试图登录哪个账号、成没成"。
`interaction_audit.actor_id` 可空,正是为失败场景准备的。
4. **绝不记录密码**。`detail` 里只有用户名与失败原因,没有任何形式的 password 字段。
5. **密码哈希用 bcrypt**。`cryptography` 是给 JWT(RS256)用的,它不提供密码哈希。
"""
from __future__ import annotations
import logging
from datetime import UTC, datetime, timedelta
from functools import lru_cache
from pathlib import Path
from typing import Any
from uuid import uuid4
import bcrypt
import jwt
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import get_settings
from app.core.contracts import RequestContext
from app.core.errors import UnauthorizedAgentError
from app.model.audit import InteractionAudit
from app.service.identity_service import IdentityService
logger = logging.getLogger(__name__)
#: 访问令牌有效期。与 `publish_*` 脚本长期使用的 30 分钟一致;权限不放在令牌里,
#: 所以这个值只影响"要不要重新登录",不影响权限变更的生效速度。
ACCESS_TOKEN_TTL_SECONDS = 1800
#: 对外统一的失败消息。刻意不区分原因,见模块文档第 1 条。
INVALID_CREDENTIALS_MESSAGE = "用户名或密码不正确"
#: 用户不存在时用来比对的固定哈希,见模块文档第 2 条。
#: 用 `bcrypt.hashpw` 现算一次即可,不需要是"某个真实用户的密码"。
_DUMMY_HASH = bcrypt.hashpw(b"not-a-real-password", bcrypt.gensalt())
@lru_cache(maxsize=1)
def _private_key() -> str:
"""签发私钥。只在本进程内缓存,不落任何地方、不进日志。"""
settings = get_settings()
path = Path(settings.jwt_private_key_path)
if not path.is_absolute():
path = Path.cwd() / path
return path.read_text(encoding="utf-8")
def issue_access_token(user_id: int) -> tuple[str, int]:
"""签一个只含 `sub` 的访问令牌,返回 (token, expires_in 秒)。
`security.py` 的 `options={"require": [...]}` 要求
`sub/iss/aud/exp/nbf/jti` 齐全,缺任何一个都会被判非法令牌。
"""
settings = get_settings()
now = datetime.now(UTC)
expires_in = ACCESS_TOKEN_TTL_SECONDS
token = jwt.encode(
{
"sub": str(user_id),
"iss": settings.jwt_issuer,
"aud": settings.jwt_audience,
"iat": now,
"nbf": now - timedelta(seconds=5),
"exp": now + timedelta(seconds=expires_in),
"jti": str(uuid4()),
},
_private_key(),
algorithm=settings.jwt_algorithm,
)
return token, expires_in
def hash_password(password: str) -> str:
"""生成 bcrypt 哈希(成本因子用库默认值)。
与 `verify_password` 放在一起,是为了让"写密码"和"校验密码"永远用同一套算法 ——
两个工具脚本(`set_user_password.py` / `create_test_user.py`)都从这里取,
避免第三次复制粘贴出不一致的实现。
"""
return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
def verify_password(password: str, stored_hash: str | None) -> bool:
"""常数时间的密码校验;任何异常都当校验失败。
`stored_hash` 在本项目的现状是**占位符**(种子写 `'x'`、worker 身份写
`!worker-only-no-password-login!`),它们都不是合法 bcrypt 格式,
`bcrypt.checkpw` 会抛 `ValueError` —— 必须吞掉并返回 False,
否则"没设过密码的账号"会变成 500 而不是 401。
"""
if not stored_hash:
return False
try:
return bcrypt.checkpw(password.encode("utf-8"), stored_hash.encode("utf-8"))
except (ValueError, TypeError):
return False
class AuthService:
"""登录入口。只依赖一个数据库会话,不持有请求上下文(登录时还没有身份)。"""
def __init__(self, session: AsyncSession) -> None:
self.session = session
async def login(self, username: str, password: str, *, trace_id: str) -> dict[str, Any]:
"""校验账号密码并签发令牌。
失败一律抛 `UnauthorizedAgentError`(401 `AUTHENTICATION_REQUIRED`),
由 `app/main.py` 的 `AgentError` 处理器输出 `docs/05` §3.4 的统一错误信封。
"""
row = (
await self.session.execute(
text(
"SELECT id, username, password_hash, status "
"FROM sys_user WHERE username = :username LIMIT 1"
),
{"username": username},
)
).mappings().first()
# 不存在时也跑一次 bcrypt,让"查无此人"与"密码错"的耗时一致(见模块文档第 2 条)。
# 注意不能指望 `verify_password(password, None)` 代劳 —— 它对空哈希直接返回 False,
# 那就等于"查无此人"立刻返回,时序差异照样能用来枚举账号。
stored_hash = str(row["password_hash"]) if row is not None else None
matched = verify_password(password, stored_hash)
if row is None:
verify_password(password, _DUMMY_HASH.decode("utf-8"))
await self._audit(
actor_id=None,
action_type="auth.login_failed",
detail={"username": username, "reason": "user_not_found", "trace_id": trace_id},
)
raise UnauthorizedAgentError(INVALID_CREDENTIALS_MESSAGE)
user_id = int(row["id"])
if not matched:
await self._audit(
actor_id=user_id,
action_type="auth.login_failed",
detail={"username": username, "reason": "bad_password", "trace_id": trace_id},
)
raise UnauthorizedAgentError(INVALID_CREDENTIALS_MESSAGE)
# 账号停用、角色读取失败等一律归到同一条 401:身份解析走的就是请求期那条链路,
# 保证"能登录"与"登录后能用"用的是同一套判断。
try:
resolved = await self._resolve(user_id, trace_id)
except Exception as exc:
await self._audit(
actor_id=user_id,
action_type="auth.login_failed",
detail={
"username": username,
"reason": f"identity_unavailable:{type(exc).__name__}",
"trace_id": trace_id,
},
)
raise UnauthorizedAgentError(INVALID_CREDENTIALS_MESSAGE) from exc
token, expires_in = issue_access_token(user_id)
await self._audit(
actor_id=user_id,
action_type="auth.login_succeeded",
detail={
"username": username,
"roles": list(resolved.roles),
"trace_id": trace_id,
},
)
return {
"access_token": token,
"token_type": "Bearer",
"expires_in": expires_in,
"user_id": str(user_id),
# 前端据此决定进哪个界面;**鉴权仍以库里实时数据为准**,不看这两个字段。
"roles": list(resolved.roles),
"data_scope": resolved.data_scope,
}
async def _resolve(self, user_id: int, trace_id: str) -> RequestContext:
"""复用请求期的身份解析,保证登录与后续调用看到的是同一套 RBAC。"""
identity = RequestContext(user_id=str(user_id), trace_id=trace_id)
return await IdentityService().resolve(identity)
async def _audit(
self, *, actor_id: int | None, action_type: str, detail: dict[str, Any]
) -> None:
"""登录审计。
与业务写入分开提交:登录失败时**也要**留下记录,不能因为随后抛异常而被回滚掉。
审计写失败不阻断登录流程(只告警)——否则审计表的问题会变成"谁都登不进来"。
"""
self.session.add(
InteractionAudit(
actor_type="user",
actor_id=actor_id,
portal=None,
session_id=None,
action_type=action_type,
detail=detail,
created_at=datetime.now(UTC).replace(tzinfo=None),
)
)
try:
await self.session.commit()
except Exception:
logger.warning("login audit write failed", exc_info=True)
await self.session.rollback()