Files
group_fqcd_jr/app/service/auth_service.py
T
lzf_0626 01ee68034d 新增登录接口:账号密码换访问令牌(POST /api/v1/auth/tokens)
背景:客户 / 员工 / 管理员三种身份此前无法区分。但区分逻辑其实早就完备 ——
bootstrap.py 里各 Agent 的 allowed_roles 一直是分开的(CustomerServiceAgent 只要
customer、RiskAgent 要 risk_operator/admin、PlatformProbeAgent 只要 admin),
唯独缺"怎么证明你是谁";sys_user.password_hash 字段也一直存在,只是全是占位符
(种子写 'x'、worker 身份写 !worker-only-no-password-login!),从没写过真实密码。

实现:
- app/service/auth_service.py:bcrypt 校验 + 签发只含 sub 的 JWT + 审计。令牌里只放 sub
  是刻意的:角色/权限/数据范围由 IdentityService 每次请求查库解析
  (identity_repository.load_context),权限变更因此立即生效,现有鉴权链路一行未改。
- app/api/controllers/auth.py + app/api/schemas/auth.py:POST /api/v1/auth/tokens,
  响应含 roles/data_scope 供前端决定进哪个界面(鉴权仍以库里实时数据为准)。
- tools/set_user_password.py:设密码(客户 123456 / 员工 666666 / 管理员 88888888)。
  ⚠️ 脚本与文档均标注"仅限演示环境",这三种弱口令上线前必须更换。
- pyproject / requirements 加 bcrypt(cryptography 只用于 JWT,不提供密码哈希)。

安全约定(逐条有实现与测试):失败不区分原因 —— 用户不存在/密码错/账号停用返回同一条
401,否则接口就成了账号枚举器;用户不存在时也跑一次 bcrypt 以抹掉时序差异;
成功与失败都写 interaction_audit(actor_id 可空正是为失败场景准备的);绝不记录密码。

过程中踩到一个自己挖的坑:给登录路由挂了通用的 enforce_rate_limit,而它声明依赖
build_request_context ⇒ 变成"要登录先登录",所有登录都 401。改为新增
enforce_login_rate_limit:按客户端 IP 独立限流(60 秒 10 次)、不依赖认证上下文。
集成测试据此调整:注入恒放行替身隔离跨用例的计数累积,同时保留一个恒超限用例验证闸门
确实会拦 —— 不能因为加了替身就把这道防线测丢。

接口登记:docs/05 §19 加 A034;并更新 §11 —— 那里原写"JWT 签发、刷新、注销由统一身份
认证模块负责,Agent 平台不重复实现",现注明平台只做登录这一步,刷新/注销仍归该模块。

验证:ruff 干净 / mypy 183 文件 0 错 / 文档守卫 37 份无重号(此前因 docs/21 重号失败)/
unit+contract 1140 passed / integration 90 passed。
2026-09-11 20:43:21 +08:00

233 lines
10 KiB
Python
Raw 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` 早就分开了(`CustomerServiceAgent` 只要 `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 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()