本篇对应
app/auth.py(96 行)、app/access.py(50 行)、app/routers/auth_router.py。这三份小文件是全仓库安全浓度的天花板,每一行都有理由。
一、口令存储:pbkdf2_hmac 与它的三个搭档
注册时把明文口令变成数据库里的两列:
PBKDF2_ITERATIONS = 120_000
def hash_password(password: str, salt: str | None = None) -> tuple[str, str]:
"""返回 (salt, hex_hash)。"""
salt = salt or secrets.token_hex(16)
dk = hashlib.pbkdf2_hmac(
"sha256", password.encode("utf-8"), bytes.fromhex(salt), PBKDF2_ITERATIONS
)
return salt, dk.hex()
def verify_password(password: str, salt: str, expected_hash: str) -> bool:
_, h = hash_password(password, salt)
return secrets.compare_digest(h, expected_hash)
三个"为什么":
- 为什么不存明文/MD5:拖库是常态假设。明文直接完蛋;MD5 单次计算,GPU 每秒能试上百亿次。pbkdf2 把单次验证成本抬高到 12 万轮哈希(本机约几十毫秒),拖库者离线爆破的成本按倍数放大。标准库
hashlib.pbkdf2_hmac是 C 实现且经过充分审计。 - 为什么每用户独立随机 salt:相同口令 → 相同哈希的"彩虹表"攻击,被每用户不同的 salt 阻断。
secrets.token_hex(16)用密码学安全随机源。 - 为什么校验用
secrets.compare_digest:普通==比较在第一个不匹配字节就返回,响应时间的微小差异理论上可被用来逐字节猜哈希(时序攻击)。compare_digest恒时比较,抹平这条侧信道。同理verify_dummy在用户不存在时也跑一次等价代价的哈希——否则"响应快 = 用户不存在"能被用来枚举用户名。安全从不在于单点强,而在于把侧信道一条条堵死。
登录接口的完整时序(auth_router.py):查用户 → 不存在则 verify_dummy(时序均衡)→ verify_password → 失败计数并写审计 → 成功 create_session 签发 token。另外登录还有防爆破锁:同一"用户名+IP"15 分钟内失败 5 次即临时锁定(LoginThrottle 类,纯内存滑动窗口,扩容时换 Redis)。
二、会话:不透明 token + 服务端存储
def create_session(conn, user_id: int) -> str:
token = secrets.token_hex(32) # 64 个十六进制字符的高熵随机串
conn.execute(
"INSERT INTO sessions(token,user_id,created_at,expires_at) VALUES(?,?,?,?)",
(token, user_id, now(), session_expiry()),
)
conn.commit()
return token
设计要点:
- token 本身不含任何信息(不是 JWT):就是一把随机钥匙,语义全在服务端
sessions表。想踢人、想改有效期、想知道谁在线——删行/改行就行。JWT 的"无状态"优点(不用查库)在此规模是伪需求,而它"签发后无法撤销"的缺点是真实的安全负担。 - 有效期 7 天(
AIP_SESSION_TTL_HOURS可调):make_user_dependency鉴权时校验expires_at,过期当场删除该行并返回 401"登录已过期,请重新登录"。 - 改密后踢掉其它设备:
delete_user_sessions(conn, user_id, keep_token=当前token)——口令改了,旧会话全部作废,但别把用户当前这台设备也踢下线(体验细节)。 - 过期会话定期清理:
purge_expired_sessions在登录/登出时顺带执行,防止 sessions 表无限膨胀。
前端拿到 token 后存 localStorage,此后每个请求带 Authorization: Bearer <token>。
三、FastAPI 依赖注入:权限检查的正确打开方式
这是全仓库最值得学习的 FastAPI 模式。auth.py 提供两级依赖工厂:
def make_user_dependency(get_db_path):
"""构造绑定到具体 app 的当前用户依赖。get_db_path(request)->str"""
from fastapi import Request
def dep(request: Request, authorization: str = Header(default="")) -> dict:
if not authorization.lower().startswith("bearer "):
raise HTTPException(status_code=401, detail="未登录或缺少 Token")
token = authorization.split(" ", 1)[1].strip()
conn = get_conn(get_db_path(request))
try:
row = conn.execute(
"SELECT u.*, s.expires_at AS session_expires_at "
"FROM sessions s JOIN users u ON u.id = s.user_id WHERE s.token=?",
(token,),
).fetchone()
if row is not None:
expires_at = row["session_expires_at"] or ""
if not expires_at or expires_at <= now():
conn.execute("DELETE FROM sessions WHERE token=?", (token,))
conn.commit()
row = None
finally:
conn.close()
if row is None:
raise HTTPException(status_code=401, detail="登录已过期,请重新登录")
user = _row_to_user(row)
user["db_path"] = get_db_path(request)
user["token"] = token # 内部字段:对外返回用户信息前必须 pop
return user
return dep
def make_admin_dependency(user_dep):
"""在 user_dep 基础上要求 admin 角色。"""
def dep(user: dict = Depends(user_dep)) -> dict:
if user["role"] != "admin":
raise HTTPException(status_code=403, detail="需要管理员权限")
return user
return dep
用法(路由侧):
@router.get("/users")
def list_users(admin: dict = Depends(admin_dep)): # 后台接口统一挂 admin 依赖
...
@router.get("/conversations")
def list_conversations(user: dict = Depends(user_dep)): # 登录即可
...
为什么这么好:
- 权限检查声明式:看函数签名就知道这接口要什么权限,不可能"忘记写检查";
- user_dep 里"查库 → 验会话 → 过期处理"只写一遍;
- admin_dep 组合 user_dep(Depends(user_dep) 链式复用),"admin 一定是登录用户"由结构保证,不靠自觉;
- 工厂模式(make_xxx_dependency(get_db_path))让依赖绑定到具体 app 实例——又是 03 篇的应用工厂红利。
细节:user["token"] 是内部字段(改密接口要用),路由对外返回用户信息前必须 pop 掉 token/db_path——仓库注释明确写了这条纪律。
四、可见性判定:收敛到 access.py 的唯一真相源
需求(01 篇):员工只能看到"本部门 + 被显式授权"的助手;知识库按部门过滤。规则本身 10 行,真正的功夫在放在哪:
# access.py —— 文件开头的自白
"""可见性与访问控制规则:助手与知识库统一按「部门匹配 + 显式授权」判定。
...
聊天发消息、RAG 检索、工作流 knowledge_search 节点都必须走这里的同一套判定,
避免"列表处校验、使用处漏校验"的横向越权。
"""
def assistant_accessible(conn, assistant_row, user: dict) -> bool:
"""岗位助手可见性。assistant_row 需含 id/departments 字段。"""
if user.get("role") == "admin":
return True
allowed = split_depts(assistant_row["departments"])
if not allowed or _overlaps(user.get("department", ""), allowed):
return True # departments 为空 = 全部门可用
granted = conn.execute(
"SELECT 1 FROM user_assistants WHERE user_id=? AND assistant_id=?",
(user["id"], assistant_row["id"]),
).fetchone()
return granted is not None
一个值得复述的教训
设想最常见的写法:列表接口过滤了部门,开发者觉得"列表都看不见了,用不到"。但攻击者直接调详情接口/发消息接口——id 是可以遍历的。这就是横向越权(IDOR)的典型来源:判定逻辑散落各处,总有漏网的。
《项目原理.md》把这类问题列为"访问判定统一收敛"专项:assistant_accessible / kb_accessible 成为唯一真相源,四个调用点全部走它——
- 列表接口(
list_assistants过滤); - 详情接口(
get_assistant403); - 发消息时的复查(
_load_conv_and_assistant)——"授权可能在建会话之后被收回:发消息时必须复查,否则被收回权限的员工仍可借旧会话继续使用该助手与 RAG"(chat_router 注释原话); - 工作流的
knowledge_search节点(运行者无权访问该库时返回空,不泄漏内容)。
配套测试:test_auth_permissions.py 的 test_conversation_data_isolation 等用例专门覆盖"员工 A 访问员工 B 的资源 → 403/404"。
给小白的心法:权限规则 = 代码 + 纪律。代码只有 50 行,纪律是"任何新接口涉及助手/知识库/他人数据,必须调用这同一组函数,禁止自写判定"。
五、注册与改密的口径统一
两处容易分裂的地方,仓库显式对齐了:
- 部门白名单:注册只能从
ALLOWED_DEPARTMENTS(运营部/产品部/开发部/管理部/客服部/财务部/人事部)选——注释一针见血:"助手与知识库的可见性都按部门字符串匹配,开放自填等于自助提权"(注册个"管理部"就全可见了)。 - 口令长度下限:注册、后台建用户、管理员重置共用同一个下限(6 位)——否则出现"只有后台能设 1 位密码"的口径分裂(《项目原理.md》§3 原话)。
- 管理员代设的初始口令强制
must_change_password=1;用户首次登录必须换掉。出厂admin/admin123也被同样的机制盯着(04 篇种子数据那段)。
六、动手清单
- 实现
hash_password/verify_password/create_session/make_user_dependency/make_admin_dependency - 写
POST /api/auth/register与POST /api/auth/login(照第五节补白名单与审计),curl 走通"注册 → 登录 → 带token访问 /api/auth/me → 改密 → 旧token失效"全流程 - 验证强制改密:用出厂口令登录,观察
must_change_password: true字段 - 【安全实验】新建两个员工账号,用员工 A 的 token 访问员工 B 的会话接口,确认 403——然后想一想:如果你的新接口忘了挂
_load_own_conversation这类检查,哪里能兜底?(答案:没有兜底,权限就是纪律)
常见坑:
- pbkdf2 的 salt 参数要 bytes.fromhex(salt),直接传 hex 字符串会 TypeError
- Depends(user_dep) 忘了加 → 接口"裸奔"不报错——上线前逐个路由核对签名
- compare_digest 对 str 要求同类型(都是 str),混用 bytes 会报错
下一篇进入 AI 部分的第一个模块:Provider 抽象层——为什么先做一个"假的"模型,反而是最专业的第一步。