Skip to content

鉴权、CSRF 与授权

本章目标:

  1. 讲清 DeerFlow 作为 LangGraph super agent harness,为何需要 JWT 会话 + CSRF + 进程内内部鉴权 + 按用户隔离这一整套防线,以及它们各自挡住什么攻击面。
  2. 串通从浏览器登录到状态变更请求的完整鉴权链:中间件栈顺序、双层 Cookie/Header 校验、token_version 吊销、按线程 owner 检查。
  3. 说透 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.userdeerflow.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 依次注册 AuthMiddlewareCSRFMiddleware → 可选 CORSMiddleware app.py:307-324,因此请求进入顺序为 CORS → CSRF → Auth → 路由 → authz 装饰器。CSRF 在 Auth 之前先跑,使得即便 Cookie 携带合法 JWT,跨站伪造请求也会被早早拒绝(与 langgraph_auth.py 注释一致 langgraph_auth.py:65-67)。

Source 列表:

Components / Subsystems

JWT(auth/jwt.py)

职责:签发与验签会话令牌。create_access_token 以 HS256 编码 {sub: user_id, exp, iat, ver},vertoken_version;decode_token 返回 TokenPayload 或具体 TokenError(过期 / 签名无效 / 格式错误)。关键类:TokenPayload(含 ver 字段)、函数 create_access_token / decode_token auth/jwt.py:12-56

Password(auth/password.py)

职责:带版本号的密码哈希。当前 v2bcrypt(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_roleneeds_setup。关键类:LocalAuthProvider(实现抽象 AuthProvider)auth/local_provider.py:13-105AuthProvider 抽象基类定义 authenticate / get_user 契约 auth/providers.py:6-24User 模型含 id(UUID)、password_hash(可空,OAuth 用户)、system_roleneeds_setuptoken_version auth/models.py:15-42

Credential File(auth/credential_file.py)

职责:把首启/重置生成的管理员密码写入 0600 受限文件而非日志,规避 py/clear-text-logging-sensitive-data 类密钥扩散。write_initial_credentialsos.open(... O_TRUNC, 0o600) 原子创建 {base_dir}/admin_initial_credentials.txt,以 label 区分 "initial" 与 "reset" auth/credential_file.py:21-48reset_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_tokensecrets.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:570manager.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 把缺失行也判为拒绝用于删除/改写路由。关键类:AuthContextPermissions authz.py:48-302AuthMiddleware 通过后已预填 request.state.auth = AuthContext(user, _ALL_PERMISSIONS),使装饰器短路掉二次 JWT 解码 auth_middleware.py:116-122

LangGraph Auth(langgraph_auth.py)

职责:langgraph.jsonauth.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-109resolve_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-203AuthMiddleware 的内部令牌分支同样用 secrets.compare_digest 防时序侧信道 internal_auth.py:19-21

速查表

Provider / 中间件角色触发条件失败响应Source
AuthMiddlewarefail-closed 401 安全网非公开路径401 NOT_AUTHENTICATED / TOKEN_INVALID / TOKEN_EXPIRED / USER_NOT_FOUNDauth_middleware.py:75-127
CSRFMiddlewareDouble Submit CookiePOST/PUT/DELETE/PATCH 且非 /api/v1/auth/me403 cross-site / missing / mismatchcsrf_middleware.py:180-220
internal_auth进程内同进程调用X-DeerFlow-Internal-Token 匹配落回 Cookie 校验 → 401internal_auth.py:19-26
LocalAuthProvider邮箱/密码认证/login/localNone(凭证错误)local_provider.py:24-59
password v2SHA-256+bcrypt 哈希创建/校验密码verify 返回 False(含畸形哈希)password.py:38-58
@require_permission资源权限 + owner 检查装饰被保护路由401 / 403 / 404authz.py:197-302
langgraph_authLangGraph 兼容鉴权langgraph.json auth.path401 / 403langgraph_auth.py:57-111
OAuth (github/google)占位未实现/oauth/{provider}501 Not Implementedauth.py:464-493

Configuration

配置项含义默认安全级别Source
AUTH_JWT_SECRETJWT HS256 签名密钥未设则自动生成并持久化到 .jwt_secret(0600)关键:生产必须显式设置,否则启动告警;泄露=可伪造任意会话auth/config.py:23-79
token_expiry_daysJWT 有效期(天)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 独立计数,需换 Redisauth.py:148-264
access_token Cookie会话载体HttpOnly,samesite=lax,secure 随 HTTPS关键:HTTPS 下才设 max_age/secureauth.py:134-145
csrf_token CookieCSRF 双提交载体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-375deps.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-137services.py:140-155
  • 登录限流是进程内 dict:多 worker(gunicorn -w N)下攻击者实际可尝试 N × 5 次再被全锁,生产多 worker 应替换为 Redis/DB 计数 auth.py:148-156

References

页面关系
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 链的对照

公司内部参考 · 由 claude-wiki-gen 基于源码自动生成的二次分析