- tools/create_test_user.py:一条命令建"可登录的测试账号"(用户 + 角色 + bcrypt 密码), 并用 IdentityService.resolve 打印**真实解析结果**。sys_user / sys_user_role 没有 ORM 模型、全靠裸 SQL,手写容易漏必填字段;更要紧的是 assigned_at 那个静默陷阱(见下)。 重复执行同一 --id 是覆盖语义,改角色也用它。 - tools/set_user_password.py:hash_password 改从 auth_service 取,消除第二份实现。 - app/service/auth_service.py:新增 hash_password,与 verify_password 放在一起, 让"写密码"和"校验密码"永远同一套算法。 - docs/29-Agent组员登录接口使用说明.md:给组员的转交文档(接口契约、加人步骤、 前端接入示例、常见问题、当前边界)。 文档里专门写清三条最容易踩的: 1. 登录用 username 而不是用户 id —— 演示账号是 cust_t / risk_t / admin_t, 不是 9001/9002/9003。这条不写明,联调时一定有人按 id 试。 2. sys_user_role.assigned_at 的 DATETIME(0) 毫秒舍入陷阱:落在未来会让账号 "登录成功但 roles=()",**不报错**。create_test_user 统一往前留 5 秒。 3. roles / data_scope 只用于前端分流界面,不是权限凭证 —— 鉴权每次请求查库解析, 所以权限变更立即生效,前端也不该拿它们做安全判断。 另:create_test_user 的 ON DUPLICATE KEY UPDATE 用 MySQL 8.0.19+ 的 `AS new` 别名语法, 避开已弃用的 VALUES()(实测本机 8.0.27 会打弃用警告)。 验证:文档守卫 38 份无编号冲突 / ruff 干净 / mypy 183 文件 0 错 / 登录集成测试 10 passed。
243 lines
10 KiB
Python
243 lines
10 KiB
Python
"""账号密码登录:校验密码、签发访问令牌、留痕审计。
|
||
|
||
## 为什么放在平台侧
|
||
|
||
认证是**平台级能力**:所有业务域共用同一套 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 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()
|