🏠 总目录📚 本教程 08 · 认证与多租户
📑 本页目录(点开跳转)

08 · 认证、会话与多租户

40 分钟 | ⭐ 别自己存密码;也别指望「记得写 WHERE tenant_id」


🎯 一句话

认证的成本不在「验密码」那一步,多租户的风险也不在「加个字段」那一步 —— 两件事真正要命的地方都在于:出了错,没有任何东西会告诉你。 所以这一章给的是两个选型建议,和一套让「忘了加租户过滤」写不出来的结构。


🧩 一、先把两个词分清

问的是 答错的后果
认证 Authentication 你是谁? 陌生人变成了张三
授权 Authorization 你能做什么? 张三看见了李四公司的数据

一个判断技巧:认证在整条链路上只做一次(进门时),授权在每次访问资源时都要做。 所以认证可以整块外包,授权永远外包不掉 —— 只有你知道「这份文档属于谁」。


🔑 二、三条路,和一个明确的建议

你要维护什么
① 自己做密码登录 哈希、注册、登录、改密、忘密、邮件、验证码、限流、撞库检测、多设备登出、合规
② OAuth / OIDC 第三方登录 一段回调逻辑 + 账号绑定关系
③ 托管认证服务 🗓️ 一段校验凭证的代码 + 配置

建议就一句:绝大多数人应该走 ② 或 ③。 不是因为你写不出密码校验 —— 那反而是最简单的一格;是因为写完那一格之后还有十几格

⚠️ 就算只做第一格,也有三个「知道才会写」的细节:慢哈希(scrypt / argon2;不能用 SHA-256,它太快,而快正是攻击者要的)、每人一份独立盐(否则一张彩虹表通吃)、等时比较== 会泄漏「第几个字节开始不同」)。还要把算法和代价参数存进哈希串,否则日后升级算法时老记录你验都验不动。 ⚠️ 这块代码的特点是「写完就再没人碰」,没有任何日常信号会提醒你算法选错了 —— 等泄库时才发现存的是无盐 MD5,而用户在别的站用了同一个密码。


登录成功后,下一次请求怎么证明「还是我」?只有两种形状:

Cookie(浏览器自动带) Bearer(前端手动放进 header)
跨域 要配 CORS + SameSite 天然好办
XSS ⭐ 可设 HttpOnly,JS 读不到 存在 JS 能读的地方就有风险
CSRF ⚠️ 有(自动带 = 别人的页面也能替你带)
适合 同域网页应用 移动端 / 跨域 / 第三方调用

选型一句话:只有网页且同域 → Cookie + HttpOnly + Secure + SameSite=Lax;有移动端或跨域 → Bearer。两个都要 → 后端都收,但只在一个地方解析(下一节)。

凭证本身有两种做法:查表式(服务端存会话表,能立刻吊销,但每次请求查库)和签名自包含payload.签名,不查库)。后者两个反直觉的点:⚠️ payload 只是 base64、不是加密,里面的用户 id、租户 id 谁都能看见,别塞敏感信息;⚠️ 自包含 = 吊销难,点了「退出所有设备」,已签发的凭证在过期前照样能用 —— 务实解法是分钟级短时效访问凭证 + 长时效刷新凭证,吊销只让刷新那一侧失效。

⚠️ 流式请求带凭证的坑(第 10 章会正面撞上)

浏览器原生的 EventSource 不能自定义请求头,也就是带不了 Authorization。于是很多人塞进 URL:/stream?token=xxx。⚠️ 这是个坏主意 —— URL 会进服务端访问日志、反向代理日志、浏览器历史、Referer,一份凭证抄成四份,而日志通常不脱敏、留存很久。

三条出路:① 同域 Cookie(自动带、不进 URL);② 换 fetch + ReadableStream(能自己设 header,第 10 章的主推做法);③ 换一张几十秒过期的一次性票据


🧷 四、用依赖注入拿到「当前用户」

第 3 章埋过一个伏笔:依赖注入的价值不在少写几行,在于让「必须做的事」没法被跳过。 ❌ 每个接口开头写 user = check_auth(request):三十个接口漏写一个,那个接口就匿名可访问,而且不报错。 ✅ 声明成参数依赖:想用 user 就必须声明它,没声明就没有 user 可用 —— 「用了却没验」写不出来。

# 一处解析、处处可用(⚠️ 未实跑,需要 fastapi + uvicorn 🗓️)
from dataclasses import dataclass

from fastapi import Depends, FastAPI, HTTPException, Request

app = FastAPI()


@dataclass
class Principal:
    user_id: str
    tenant_id: str          # ⭐ 和用户 id 绑死,谁也别想只拿一半


def current_principal(request: Request) -> Principal:
    h = request.headers.get("authorization", "")
    raw = request.cookies.get("sid") or (h[7:] if h[:7].lower() == "bearer " else "")
    if not raw:                       # ⭐ Cookie 和 Bearer 两种来源在同一处收敛
        raise HTTPException(status_code=401, detail="未登录")
    body = verify_token(raw)          # 验签 + 查过期
    return Principal(body["sub"], body["tid"])


@app.get("/api/docs")
def list_docs(me: Principal = Depends(current_principal)):
    return {"tenant": me.tenant_id}   # ⭐ 到这里 me 一定已验签

Principal 把两个 id 绑死,这是下一节整套防御的地基:⚠️ 租户号只能来自已验签的凭证,永远不能来自请求参数。 GET /api/docs?tenant=globex 是本章最贵的一行代码 —— 改一个字符就是越权。


🏢 五、多租户:AI 产品最容易出事故的地方

多租户 = 一套服务同时服务多家客户。bug 不再是「用户看到不该看的按钮」,而是「A 公司看到了 B 公司的合同」。

⚠️ 代价
① 共享表 + tenant_id(靠 WHERE 分开) 最省、加租户零成本 隔离全靠代码正确;大租户拖慢小租户
② 每租户一个 schema 误查跨不过去、按租户备份 改表要循环 N 次;上千租户时迁移和连接池都难受
③ 每租户一个库 隔离最强、可单独扩容 成本和运维复杂度乘 N

默认选 ①;有合规硬要求或超大客户单独付钱,就对那几个用 ③ —— 混合是最常见的真实答案。 ⚠️ 向量库同档:embedding 里带着原文的信息,别因为「它只是一堆浮点数」就放松。

⚠️ 为什么「忘了加 WHERE tenant_id」特别可怕

普通 bug 会报错、会崩、会被测试抓住。这个不会:

  1. 不报错 —— 少一个条件,SQL 照样合法执行,只是多返回几行
  2. 测试环境看不出来 —— 测试库大概率只有一个租户,加不加条件结果一模一样
  3. 静默越权 —— 没有 403、没有异常、没有告警,只有客户某天打电话说「我搜到了别人公司的名字」
  4. 不可撤销 —— 数据已经渲染在对方屏幕上了,事后改代码撤销不了「已经被看见」

所以这类问题不能靠「记得写」防,只能靠「写错跑不通」防。 任何形如「所有人都要记得在每个查询里加 X」的规范,都会在第 200 个查询上失效 —— 不是人不专业,是概率。

⭐⭐ 三层结构性防御

第一层:业务代码根本拿不到「不带租户」的入口。不给裸连接,只给一个已绑定租户的句柄,租户条件由句柄自己拼:

# 让「忘了加 WHERE tenant_id」写不出来(sqlite3,可直接跑)
import sqlite3

TENANT_TABLES = {"docs"}       # ⭐ 声明哪些表属于租户


class TenantDB:
    """业务代码只能拿到它,拿不到裸 connection。"""

    def __init__(self, conn, tid):
        self._conn, self._tid = conn, tid

    def select(self, table, where="1=1", args=()):
        if table not in TENANT_TABLES:
            raise ValueError("表 %s 不在租户表清单里" % table)
        # ⭐ 租户条件由句柄拼,不由调用方拼 —— 调用方没有「忘记」的机会
        sql = "SELECT * FROM %s WHERE tenant_id = ? AND (%s)" % (table, where)
        return self._conn.execute(sql, (self._tid,) + tuple(args)).fetchall()

    def insert(self, table, title):     # ⭐ 写入也由句柄盖租户章
        self._conn.execute("INSERT INTO %s (tenant_id, title) VALUES (?,?)" % table,
                           (self._tid, title))


if __name__ == "__main__":
    conn = sqlite3.connect(":memory:")
    conn.execute("CREATE TABLE docs (id INTEGER PRIMARY KEY, tenant_id TEXT, title TEXT)")
    TenantDB(conn, "acme").insert("docs", "Acme 的季度报告")
    TenantDB(conn, "globex").insert("docs", "Globex 的裁员名单")

    print("acme 看到:", [r[2] for r in TenantDB(conn, "acme").select("docs")])
    for table in TENANT_TABLES:                # 🧪 毒丸测试:换个租户必须 0 行
        assert TenantDB(conn, "nobody").select(table) == []
    print("⚠️ 绕过句柄就没用了:", [r[2] for r in conn.execute("SELECT * FROM docs")])

最后一行是故意留的 —— 这层只防手滑,不防绕过。所以三层一起上:① 句柄挡手滑;② 毒丸测试(每张租户表都断言「换个租户查出 0 行」,⭐ 按表自动生成,加表忘了防护测试直接红)挡新表和新查询路径;③ Postgres 行级安全 RLS ⭐⭐ 挡绕过 —— 裸 SQL 也照样被拒:

-- ⚠️ 未实跑,需要 PostgreSQL
ALTER TABLE docs ENABLE ROW LEVEL SECURITY;
ALTER TABLE docs FORCE ROW LEVEL SECURITY;      -- ⭐ 连表的属主也一起管住
CREATE POLICY tenant_isolation ON docs
  USING (tenant_id = current_setting('app.tenant_id', true))
  WITH CHECK (tenant_id = current_setting('app.tenant_id', true));

用法:从连接池借出连接后,SET LOCAL app.tenant_id = '...'(值来自已验签的凭证)再跑业务 SQL。⚠️ 必须 SET LOCAL(随事务结束自动清掉),否则连接还回池子时还带着上一个租户的身份 —— 随机串号比忘写 WHERE 更难查。

💀 事故的典型形状:某天加了个「全局搜索」接口,作者为了性能绕过仓储层直接拼了句 LIKE,结果混进了别家租户的文档标题(标题本身就带客户名)。没被发现:单测种子只有一个租户,少条件结果完全一样;评审看的是注入和索引;没有任何监控会因为「多返回几行」而波动

最便宜的一条防线:让测试种子数据里永远至少有两个租户。 它把「静默的越权」变成「一跑就红的断言」,成本几乎为零。


🔁 六、换个栈怎么对应

概念(不会过期) Python / FastAPI Node(Express / Hono) Go
从请求解析身份 Depends(current_principal) 中间件写进 req.user / c.set() 包一层 Handler,写进 context.Context
强制不可跳过 声明式参数依赖 ⭐ 路由分组挂中间件;⚠️ 忘挂就漏 只暴露「已鉴权」的注册函数
签名凭证 / Cookie 属性 成熟 JWT 库 🗓️;HttpOnly/Secure/SameSite 同名,写法不同 同名
绑定租户的数据句柄 / 数据库兜底 仓储类或 ORM 全局过滤 + Postgres RLS

换语言换掉的只是「中间件」还是「依赖」这个叫法。 「身份只能来自已验签的凭证」「租户条件由框架拼」「数据库层再兜一次」这三条,到哪个栈都一样。


🔗 这一章连到哪里

去哪 为什么
数据这一关 19 · 隐私脱敏与留存 隔离解决「谁能看」,那一章解决「本来就不该存」。⭐ 日志、prompt、向量库都在沉淀用户原文,隔离再好也挡不住存了不该存的东西
智能体工程教程 15 · 安全-沙箱与提示注入 本章防「代码写漏」,那章防「模型被骗」。⚠️ 两者会叠加:模型被注入后会主动去要别人的数据,所以隔离必须做在数据层
智能体工程教程 16 · 企业落地与生产化 那一章「合规与风险」一节把 To B 采购真正卡的关列成了表:数据去向、PII 脱敏、⭐ 可追溯的审计日志、高风险决策要有人工介入。⭐ 本章的租户隔离是其中「数据边界」那一条的实现侧,那一章讲的是为什么签不了单
03 · 后端骨架 ⭐ 那一章的 current_user 依赖是个空壳挂载点,明说了「留给第 8 章」。第四节的 current_principal 就是把它填实的那一版 —— 「依赖注入让必须做的事没法被跳过」这个伏笔在这里兑现
07 · 向量检索落地 ⭐⭐ 那一章的检索 SQL 里有句 WHERE user_id = $2它绝不能是可选的;⚠️ 向量表同样要进本章的租户表清单和毒丸测试 —— embedding 里带着原文的信息,别因为「它只是一堆浮点数」就漏掉
⭐⭐ 08b · 同源策略与 CORS 本章第三节那张表只说了跨域「要配 CORS + SameSite」,没说怎么配。那一章讲透同源策略、预检请求 —— ⚠️ 16 章那条 CORS 检查项以前写着「本板块未展开」,现在指的就是它
⭐⭐ 08c · CSRF 与 XSS 本章的对照表里 CSRF 和 XSS 各占一格(「自动带 = 别人的页面也能替你带」)。那一章把这两格展开成两套完全不同的防线 —— ⭐ 也终于给第 10 章那套净化判据起了名字
⭐⭐ 08d · OAuth2 与 OIDC 本章第二节劝你「别自己存密码,用 OAuth / 托管认证」,但对这条路的全部交付只有表格里一格(「一段回调逻辑 + 账号绑定关系」)。那一章把那一格展开:授权码流程为什么要绕一圈、state 防什么、PKCE 解决什么
08e · JWT ⚠️ 本章第三节末尾那段「查表式 vs 签名自包含、吊销难」只讲了两百字就停了。那一章从这里接着讲:三段结构、四种绕过、以及吊销真正的几种解法
10 · 把流式接到界面上 第三节「EventSource 带不了 Authorization」那个坑,在那一章正面解决

✅ 检查点

  1. 认证和授权分别问什么?为什么说认证能外包、授权外包不掉?
  2. 三条登录路建议走哪条?自己存密码有哪三个「知道才会写」的细节?
  3. Cookie 和 Bearer 各怕哪种攻击?同域网页选哪个、配哪三个属性?
  4. 签名自包含凭证的两个反直觉点是什么?为什么不能把 token 放进 ?token=xxx
  5. 依赖注入比「每个接口开头调一次 check_auth」强在哪?
  6. 「忘了加 WHERE tenant_id」为什么特别可怕(四点)?三层防御各挡住什么?
👀 答案
  1. 认证问「你是谁」,授权问「你能做什么」。认证整条链路只做一次、形状对所有产品都一样,所以能外包;授权每次访问资源都要做,只有你知道「这份文档属于谁」。
  2. ② OAuth 或 ③ 托管服务 —— 密码校验是最简单的一格,后面还有改密、忘密、验证码、限流、撞库、多设备登出、合规十几格。三个细节:慢哈希每人一份独立盐等时比较
  3. Cookie 怕 CSRF(自动带 = 别人的页面也能替你带),但能 HttpOnly 躲开 XSS;Bearer 没 CSRF,但放在 JS 能读的地方就怕 XSS。同域网页选 Cookie + HttpOnly + Secure + SameSite=Lax
  4. payload 只是 base64 不是加密,别塞敏感信息;② 吊销难(解法:短时效访问凭证 + 长时效刷新凭证)。token 不能进 URL,因为它会被服务端日志、代理日志、浏览器历史、Referer 各抄一份。
  5. 手写 check_auth 时三十个接口漏一个,那个接口就匿名可访问且不报错;声明成参数依赖后,想用 user 就必须声明它 —— 「用了却没验」写不出来。
  6. 不报错测试环境看不出来(测试库通常只有一个租户)、静默越权(无 403、无异常、无告警)、不可撤销。三层:句柄挡手滑,毒丸测试挡新表和新查询路径RLS绕过(要 FORCE,且必须 SET LOCAL,否则连接还池时串号)。

🛑 可以停在这里

走神救援

认证问「你是谁」(进门一次),授权问「你能做什么」(每次访问资源)—— 所以认证能外包、授权外包不掉。三条登录路:自己做密码 / OAuth / 托管服务,⭐ 建议后两条:密码校验是最简单的一格,后面还有改密、忘密、验证码、限流、撞库、多设备登出、合规十几格;真自己做则慢哈希、每人独立盐、等时比较缺一不可。会话两种形状:Cookie(自动带、能 HttpOnly 躲 XSS、⚠️ 怕 CSRF)和 Bearer(手动放 header、跨域方便、⚠️ 怕 XSS)—— 同域网页选 Cookie + HttpOnly + Secure + SameSite=Lax。签名自包含凭证两点:payload 只是 base64 不是加密吊销难(解法:短时效访问凭证 + 长时效刷新凭证)。⚠️ EventSource 带不了 Authorization,但别把 token 塞进 query —— URL 会被服务端日志、代理日志、浏览器历史、Referer 各抄一份。依赖注入的价值是把漏写变成写不出来:想用 user 就必须声明它;⭐ 租户号只能来自已验签的凭证,永不来自请求参数多租户三档:共享表加 tenant_id(默认)/ 分 schema / 分库。⚠️ 「忘了加 WHERE tenant_id」四个可怕之处:不报错、测试库只有一个租户所以看不出来、静默越权无告警、看到了收不回。所以防线是「写错跑不通」三层:① 绑定租户的句柄(条件由句柄拼,没有忘记的机会)② 毒丸测试(每张租户表断言换个租户查出 0 行,按表自动生成)③ Postgres RLS 兜住绕过(要 FORCE,每事务 SET LOCAL)。最便宜的一条:测试种子数据永远至少两个租户

下一节 👉 08b-同源策略与CORS.md

打卡记录保存在你的浏览器里,首页能看到总进度