主题
鉴权、CSRF 与授权
本章目标:
- 讲清 DeerFlow 作为 LangGraph super agent harness,为何需要 JWT 会话 + CSRF + 进程内内部鉴权 + 按用户隔离这一整套防线,以及它们各自挡住什么攻击面。
- 串通从浏览器登录到状态变更请求的完整鉴权链:中间件栈顺序、双层 Cookie/Header 校验、
token_version吊销、按线程 owner 检查。- 说透
get_effective_user_id/resolve_runtime_user_id如何把已认证用户解析成文件系统隔离桶,以及 no-auth 模式(DEFAULT_USER_ID="default")的真实风险与升级路径。
TL;DR
DeerFlow 用 HttpOnly Cookie 携带的 HS256 JWT 做会话鉴权,AuthMiddleware 作为 fail-closed 安全网在所有非公开路径强制校验 auth_middleware.py:52-127。状态变更请求额外用 Double Submit Cookie 模式做 CSRF 防护 csrf_middleware.py:174-220。IM channel worker 这类同进程调用走 X-DeerFlow-Internal-Token 进程内随机令牌绕过浏览器会话 internal_auth.py:10-26。认证成功后用户 id 经 contextvar + run context 双通道注入,驱动 get_effective_user_id() 的按用户文件隔离,no-auth 时回退到 "default"。
Overview
DeerFlow 不是一个无状态的问答 API,而是一个会在沙箱里执行命令、写文件、跑长期记忆、派发子代理的 super agent harness。这把它的鉴权需求拉到了远超普通 Web 应用的高度:
- 每个线程的产物落到真实磁盘。线程目录是
backend/.deer-flow/users/{user_id}/threads/{thread_id}/user-data/...,user_id必须由服务端可信地解析出来,否则用户 A 能读写用户 B 的工作区。这就是为什么认证后的User要同时写入request.state.user和deerflow.runtime.user_context的 contextvar——让 repository 层透明地按 owner 过滤,而路由不必到处写user_id样板代码 auth_middleware.py:52-70。 - agent 执行常在请求返回之后才发生(后台 run、子代理线程)。仅靠请求期的 ContextVar 不够,所以 user_id 还要被注入到 run 的
context字典里跟随到工具执行 services.py:140-155。 - 浏览器是天然的 CSRF 受害体。会话存在 HttpOnly Cookie 里,浏览器会自动带上;若无 CSRF 防护,任意第三方站点都能伪造"删除线程""创建 run"等状态变更请求。
- IM channel worker 是受信的同进程调用者,但它没有浏览器会话 Cookie。需要一条独立的进程内鉴权通道,既不能用空鉴权(否则等于关掉鉴权),也不能强迫它走登录流程。
Architecture
中间件以 ASGI 栈方式注册,add_middleware 后注册的先执行(LIFO)。app.py 依次注册 AuthMiddleware → CSRFMiddleware → 可选 CORSMiddleware app.py:307-324,因此请求进入顺序为 CORS → CSRF → Auth → 路由 → authz 装饰器。CSRF 在 Auth 之前先跑,使得即便 Cookie 携带合法 JWT,跨站伪造请求也会被早早拒绝(与 langgraph_auth.py 注释一致 langgraph_auth.py:65-67)。
Source 列表:
- 中间件注册顺序:app.py:307-324
- Auth 安全网与公开路径白名单:auth_middleware.py:24-49
- CSRF 判定与免检路径:csrf_middleware.py:32-63
- 细粒度授权装饰器:authz.py:147-302
- LangGraph 兼容 auth(
langgraph.json注册):langgraph_auth.py:57-111、langgraph.json
Components / Subsystems
JWT(auth/jwt.py)
职责:签发与验签会话令牌。create_access_token 以 HS256 编码 {sub: user_id, exp, iat, ver},ver 即 token_version;decode_token 返回 TokenPayload 或具体 TokenError(过期 / 签名无效 / 格式错误)。关键类:TokenPayload(含 ver 字段)、函数 create_access_token / decode_token auth/jwt.py:12-56。
Password(auth/password.py)
职责:带版本号的密码哈希。当前 v2 为 bcrypt(b64(sha256(password)))——SHA-256 预哈希绕开 bcrypt 72 字节静默截断;v1($dfv1$ 或裸 bcrypt)为旧格式,验签时自动识别版本并在下次登录时透明升级。关键函数:hash_password / verify_password / needs_rehash,均有 *_async 线程池包装避免阻塞事件循环 auth/password.py:22-82。
Local Provider(auth/local_provider.py)
职责:邮箱/密码本地认证 + 用户 CRUD。authenticate() 校验密码后,若 needs_rehash 为真则机会式升级哈希(DB 错误不阻断登录);create_user 支持 system_role 与 needs_setup。关键类:LocalAuthProvider(实现抽象 AuthProvider)auth/local_provider.py:13-105。AuthProvider 抽象基类定义 authenticate / get_user 契约 auth/providers.py:6-24。User 模型含 id(UUID)、password_hash(可空,OAuth 用户)、system_role、needs_setup、token_version auth/models.py:15-42。
Credential File(auth/credential_file.py)
职责:把首启/重置生成的管理员密码写入 0600 受限文件而非日志,规避 py/clear-text-logging-sensitive-data 类密钥扩散。write_initial_credentials 用 os.open(... O_TRUNC, 0o600) 原子创建 {base_dir}/admin_initial_credentials.txt,以 label 区分 "initial" 与 "reset" auth/credential_file.py:21-48。reset_admin.py CLI 复用它来重置管理员密码 auth/reset_admin.py:1-23。
CSRF(csrf_middleware.py)
职责:对 POST/PUT/DELETE/PATCH 做 Double Submit Cookie 校验。csrf_token Cookie 必须 httponly=False(JS 可读以回填到 X-CSRF-Token 头),samesite="strict",secure 随 HTTPS 探测。登录/注册/登出/初始化这类首次无 token 的端点免双提交校验,改用 is_allowed_auth_origin 的同源/配置源判定防登录 CSRF 与会话固定。关键类:CSRFMiddleware;关键函数 should_check_csrf / is_allowed_auth_origin / _normalize_origin csrf_middleware.py:32-220。
Internal Auth(internal_auth.py)
职责:为同进程 Gateway 内部调用者(主要是 IM channel worker)提供进程本地鉴权。模块加载时生成一次性 secrets.token_urlsafe(32),is_valid_internal_auth_token 用 secrets.compare_digest 常量时间比对,get_internal_user() 返回 SimpleNamespace(id=DEFAULT_USER_ID, system_role="internal") 的合成用户 internal_auth.py:10-26。channel ChannelManager 在构造时生成自身 CSRF token,并在 langgraph_sdk client 上注入内部令牌头 + 配对的 CSRF Cookie/Header manager.py:570、manager.py:641-648。
Authz(authz.py)
职责:细粒度权限与按资源 owner 检查。@require_auth 独立强制认证(不依赖中间件存在);@require_permission(resource, action, owner_check, require_existing) 检查 resource:action 权限,owner_check=True 时经 ThreadMetaStore.check_access 校验线程归属(缺失行/user_id NULL 视为放行,只有"存在且属于他人"才 404),require_existing=True 把缺失行也判为拒绝用于删除/改写路由。关键类:AuthContext、Permissions authz.py:48-302。AuthMiddleware 通过后已预填 request.state.auth = AuthContext(user, _ALL_PERMISSIONS),使装饰器短路掉二次 JWT 解码 auth_middleware.py:116-122。
LangGraph Auth(langgraph_auth.py)
职责:langgraph.json 的 auth.path 注册项 langgraph.json,供 LangGraph Studio / 直连 LangGraph Server 兼容路径使用(默认嵌入 Gateway 运行时不加载本模块)。@auth.authenticate 复用与 Gateway 相同的 cookie→decode→DB lookup→token_version 链并先做 CSRF 校验;@auth.on 注入 metadata.user_id 并返回 {"user_id": ...} 过滤器使 LangGraph Server 强制同样的线程隔离 langgraph_auth.py:57-111。
get_effective_user_id 与 user_id 解析(runtime/user_context.py)
职责:把已认证用户解析为文件系统隔离桶。get_effective_user_id() 读 contextvar,无用户时返回 DEFAULT_USER_ID="default"(永不抛错,用于路径解析)user_context.py:97-109。resolve_runtime_user_id(runtime) 是工具/中间件的唯一权威解析:优先 runtime.context["user_id"](跨后台任务边界仍可靠),其次 contextvar,最后 DEFAULT_USER_ID user_context.py:112-137。repository 层用 AUTO/str/None 三态 sentinel:AUTO 从 contextvar 读且无用户时 RuntimeError,None 显式跳过 WHERE 仅供迁移/CLI user_context.py:149-196。
Data Flow
浏览器登录 → JWT → CSRF 配对
channel worker 内部鉴权注入
Implementation Details
CSRF 中间件的核心校验(节选):auth 端点走 Origin 判定,其余状态变更走双提交常量时间比对。
python
if should_check_csrf(request) and _is_auth and not is_allowed_auth_origin(request):
return JSONResponse(status_code=403, content={"detail": "Cross-site auth request denied."})
if should_check_csrf(request) and not _is_auth:
cookie_token = request.cookies.get(CSRF_COOKIE_NAME)
header_token = request.headers.get(CSRF_HEADER_NAME)
if not cookie_token or not header_token:
return JSONResponse(status_code=403, content={"detail": "CSRF token missing. Include X-CSRF-Token header."})
if not secrets.compare_digest(cookie_token, header_token):
return JSONResponse(status_code=403, content={"detail": "CSRF token mismatch."})解读:登录类端点首次访问没有 CSRF token,因此 _is_auth 分支不查双提交,只靠 is_allowed_auth_origin 拒绝带敌意 Origin 的浏览器请求(无 Origin 头的 curl/移动端放行);其余状态变更端点必须同时带 csrf_token Cookie 与 X-CSRF-Token 头且二者用 secrets.compare_digest 常量时间相等,攻击站点无法读取受害者 Cookie 也就无法回填该头 csrf_middleware.py:183-203。AuthMiddleware 的内部令牌分支同样用 secrets.compare_digest 防时序侧信道 internal_auth.py:19-21。
速查表
| Provider / 中间件 | 角色 | 触发条件 | 失败响应 | Source |
|---|---|---|---|---|
AuthMiddleware | fail-closed 401 安全网 | 非公开路径 | 401 NOT_AUTHENTICATED / TOKEN_INVALID / TOKEN_EXPIRED / USER_NOT_FOUND | auth_middleware.py:75-127 |
CSRFMiddleware | Double Submit Cookie | POST/PUT/DELETE/PATCH 且非 /api/v1/auth/me | 403 cross-site / missing / mismatch | csrf_middleware.py:180-220 |
internal_auth | 进程内同进程调用 | X-DeerFlow-Internal-Token 匹配 | 落回 Cookie 校验 → 401 | internal_auth.py:19-26 |
LocalAuthProvider | 邮箱/密码认证 | /login/local 等 | None(凭证错误) | local_provider.py:24-59 |
password v2 | SHA-256+bcrypt 哈希 | 创建/校验密码 | verify 返回 False(含畸形哈希) | password.py:38-58 |
@require_permission | 资源权限 + owner 检查 | 装饰被保护路由 | 401 / 403 / 404 | authz.py:197-302 |
langgraph_auth | LangGraph 兼容鉴权 | langgraph.json auth.path | 401 / 403 | langgraph_auth.py:57-111 |
| OAuth (github/google) | 占位未实现 | /oauth/{provider} | 501 Not Implemented | auth.py:464-493 |
Configuration
| 配置项 | 含义 | 默认 | 安全级别 | Source |
|---|---|---|---|---|
AUTH_JWT_SECRET | JWT HS256 签名密钥 | 未设则自动生成并持久化到 .jwt_secret(0600) | 关键:生产必须显式设置,否则启动告警;泄露=可伪造任意会话 | auth/config.py:23-79 |
token_expiry_days | JWT 有效期(天) | 7(范围 1–30) | 中:越长被盗令牌窗口越大 | auth/config.py:27 |
GATEWAY_CORS_ORIGINS | 显式允许的浏览器源(逗号分隔) | 空(同源) | 关键:CORS 与 CSRF auth-origin 共用;配 * 会被丢弃 | csrf_middleware.py:96-111 |
AUTH_TRUSTED_PROXIES | 可信反代 CIDR/IP,用于 X-Real-IP 限流 | 空(直连模式,忽略 X-Real-IP) | 关键:误配会让攻击者伪造 IP 绕过登录限流 | auth.py:164-222 |
_MAX_LOGIN_ATTEMPTS / _LOCKOUT_SECONDS | 登录失败锁定 | 5 次 / 300s | 中:进程内 dict,多 worker 下每 worker 独立计数,需换 Redis | auth.py:148-264 |
| access_token Cookie | 会话载体 | HttpOnly,samesite=lax,secure 随 HTTPS | 关键:HTTPS 下才设 max_age/secure | auth.py:134-145 |
| csrf_token Cookie | CSRF 双提交载体 | httponly=False,samesite=strict | 中:必须 JS 可读,这是模式要求而非配置错误 | csrf_middleware.py:208-218 |
| 注册密码强度 | 最短 8 位 + 常见弱口令黑名单 | 内置 SecLists 子集(小写化比对) | 中:仅下限防护,非完整 HIBP 检查 | auth.py:40-103 |
Common Pitfalls / Tips
- no-auth 模式不是关掉鉴权,而是退化为单用户共享桶。当没有用户上下文时,
get_effective_user_id()返回"default",所有线程/记忆/上传都落进users/default/...——多个匿名访问者共享同一份数据,无隔离 user_context.py:97-109。注意:AuthMiddleware仍会对非公开路径要求合法 Cookie/内部令牌,纯 no-auth 共享数据主要出现在升级前的历史数据与 channel internal 调用(其合成用户 id 即DEFAULT_USER_ID)internal_auth.py:24-26。 - no-auth → with-auth 升级:首次创建管理员后,启动钩子
_ensure_admin_user会把metadata.user_id未设的 LangGraph 孤儿线程迁移给管理员,失败非致命 app.py:51-119。 require_existing容易漏配:读路由用owner_check=True时缺失threads_meta行视为放行(兼容遗留未跟踪线程);删除/改写路由必须额外加require_existing=True,否则已删除线程能被他人经"缺失行"代码路径重新命中 authz.py:210-217。- 改密会吊销所有旧会话:
change_password始终自增token_version并重发 Cookie;旧 JWT 的ver不再匹配,get_current_user_from_request直接 401 "Token revoked" auth.py:332-375、deps.py:217-221。 - 后台工具用 contextvar 不可靠:run 在请求返回后执行,工具持久化用户数据应调
resolve_runtime_user_id(runtime)而非裸get_effective_user_id(),因为前者优先读runtime.context["user_id"](由inject_authenticated_user_context从服务端 auth 状态注入,绝不取客户端 context)user_context.py:112-137、services.py:140-155。 - 登录限流是进程内 dict:多 worker(gunicorn -w N)下攻击者实际可尝试 N × 5 次再被全锁,生产多 worker 应替换为 Redis/DB 计数 auth.py:148-156。
References
- backend/app/gateway/auth_middleware.py — fail-closed 全局鉴权中间件
- backend/app/gateway/csrf_middleware.py — Double Submit Cookie CSRF
- backend/app/gateway/internal_auth.py — 进程内内部鉴权令牌
- backend/app/gateway/authz.py — 权限装饰器与 owner 检查
- backend/app/gateway/langgraph_auth.py — LangGraph 兼容 auth handler
- backend/app/gateway/auth/jwt.py — JWT 签发与验签
- backend/app/gateway/auth/password.py — 带版本号的 bcrypt 密码哈希
- backend/app/gateway/auth/config.py — JWT secret 加载/持久化
- backend/app/gateway/routers/auth.py — 登录/注册/初始化/改密端点
- backend/app/gateway/deps.py — 用户解析与 token_version 校验
- backend/packages/harness/deerflow/runtime/user_context.py — get_effective_user_id 与三态解析
Related Pages
| 页面 | 关系 |
|---|---|
| 13-Gateway-API与路由体系 | 上游:本章中间件挂在 Gateway FastAPI 应用上,路由体系是被保护的对象 |
| 26-IM通道系统 | 下游消费方:channel worker 通过 internal_auth + 配对 CSRF 调用 Gateway |
| 23-长期记忆系统 | 隔离依赖:记忆按 get_effective_user_id() 解析的 user_id 做 per-user 存储 |
| 12-中间件链机制 | 概念相邻:ASGI 中间件栈顺序与 agent middleware 链的对照 |