鉴权机制:谁能调、怎么校验
一个请求打进 DeepTutor 后端之后,“你是谁”和”你能不能调这个”是分开判定的两件事,而且判定点散落在四个文件里。要把它读明白,最好的入口不是路由表,而是 deeptutor/api/routers/auth.py 这一个文件——总开关、令牌提取、WebSocket 分支、管理员判定全在这里收口。
以下行号与常量都对应我们采集的仓库快照:HEAD 456f9c2,采集日 2026-08-10,deeptutor/__version__.py 里的版本号是 1.5.11。你 clone 之后行号可能已经漂了,文件名和标识符是稳的。
一、第一层是一个总开关,关掉之后连带三处变化
AUTH_ENABLED 来自 auth 设置(deeptutor/services/auth.py:42)。它不是”要不要弹登录框”这么单纯,关掉之后至少有三处行为跟着变:
require_auth变成 no-op,并把当前用户直接设成本地 admin(deeptutor/api/routers/auth.py:237-283)。require_admin把所有请求都当 admin,返回一个合成 payload(auth.py:327-352)。- CORS 从显式白名单切成
allow_origin_regex = r"https?://.*"(deeptutor/api/main.py:79-99、290-297);auth 开启时才要求显式配置CORS_ORIGIN(S)。
再叠上一个事实:uvicorn 绑的是 host="0.0.0.0",端口来自 get_backend_port()(deeptutor/api/run_server.py:64-79)。这几条摆在一起就是本机上一个实实在在的暴露面——尤其因为这套系统的授权项里包含”能不能调用管理员安装的命令行工具”(grant 字段 cli_apps)与”能不能执行 exec”(exec_enabled),不是只读接口(deeptutor/multi_user/grants.py:16-44)。怎么部署取决于你的网络环境,本文只陈述这几个默认值在哪一行,不给部署建议。
另有一个联动常量值得记:POCKETBASE_ENABLED = bool(POCKETBASE_BASE_URL) and AUTH_ENABLED(services/auth.py:46)。也就是说 auth 关掉时,PocketBase 分支跟着一起失效。
二、令牌形态:JWT + bcrypt + 一个 cookie
令牌方案是 JWT,库用 python-jose,算法常量 _ALGORITHM = "HS256";密码哈希用 bcrypt,注释交代了为什么不走 passlib——passlib 对 bcrypt 4+ 已不维护(services/auth.py:54、78-94、222、270)。有效期是配置项 TOKEN_EXPIRE_HOURS(services/auth.py:52),仓库没有把它钉成某个通用值,该设多久取决于你的用法。
传输侧有两条通道,require_auth 两条都收:Authorization: Bearer <token> 头,以及名为 dt_token 的 cookie(auth.py:237-283)。cookie 设了 httponly=True;SameSite 不是写死的,它跟着 cookie_secure 走——secure 时取 none,否则取 lax,注释里写明了这是在 127.0.0.1 与 localhost 的跨源问题、以及浏览器对 SameSite=None 必须配 Secure 这两个约束之间做的取舍(auth.py:25-30、59、63-76)。
注册接口这一侧的校验也很直白:用户名同时接受邮箱格式与 ^[A-Za-z0-9_\-.]{3,64}$ 这条普通用户名正则,密码至少 8 位(auth.py:100-119)。
三、最反直觉的一处:require_auth 必须写成 async def
在 FastAPI 里,依赖写成 def 还是 async def,大多数时候被当成性能取舍——同步依赖会被丢到线程池,仅此而已。这个文件里不是。
auth.py:255-262 的 docstring 把原因写死了:同步依赖会被 anyio.to_thread.run_sync 派发到工作线程执行,而工作线程拿到的是请求上下文的一份副本;在副本里做的 ContextVar.set 在线程返回时被丢弃。于是 require_auth 明明”成功”了,端点读到的却是那个 ContextVar 的未设置默认。注释指名这是 issue #481 的根因。
这一处之所以值得单独讲,是因为它的失败形态不是报错,而是身份静默漂移。把它和另一处默认行为对着看就明白了:当前用户是靠 ContextVar _current_user 承载的,get_current_user() 在未设置时会回退到 local_admin_user()(deeptutor/multi_user/context.py:117、128-129);而本地单用户身份常量就是 LOCAL_ADMIN_ID = "local-admin" / LOCAL_ADMIN_USERNAME = "local"(deeptutor/multi_user/models.py:105-106)。也就是说,一个 async 关键字的有无,决定的是”当前用户”这一格里躺的是登录者还是本地 admin,而不是快一点慢一点。
对读源码的人,这里有两条可直接迁移的经验:其一,凡是靠 ContextVar 传身份的依赖,同步/异步不是等价改写;其二,凡是”取不到就回退到某个默认身份”的设计,配上一个静默失败的写入路径,就会变成很难查的问题——因为链路上每一环看起来都成功了。
四、第二处反直觉:这里刻意不用 HTTPBearer
FastAPI 自带 HTTPBearer,这个项目手写了 Header 与 Cookie 的提取。原因同样写在注释里(auth.py:190-196):HTTPBearer 是类依赖,其 __call__ 标注了 request: Request,而 FastAPI 不向 WebSocket 依赖注入 Request——一旦带这个依赖的 router 挂上 WS 端点,就会抛 TypeError。
顺着这条线就能理解 WebSocket 侧为什么要另起一套。ws_require_auth() 有一条硬性调用顺序契约:必须在 ws.accept() 之前调用;token 从 ?token= 查询参数或 cookie 里取;失败时 ws.close(code=4001)(auth.py:294-320)。所以排查 WS 连不上时,4001 这个自定义关闭码是一个明确信号,它和网络层断开不是一回事。
装配层也留了对应痕迹:unified_ws(WS /ws)与 quiz_judge(WS /question/judge)这两个端点的 auth 是在 handler 内部检查的,注释理由就是 WebSocket 用不了常规 FastAPI 依赖(deeptutor/api/main.py:474-480)。
五、“谁能调”是三道闸,不是一道
第一道是路由级。auth 这个 router 本身是公开的,15 条路由不挂 _auth 依赖(main.py:350);/api/v1/settings 里有一条 GET /ui 特意挂在 public_router 上,理由是登录页得先拿到界面语言(main.py:410-421、deeptutor/api/routers/settings.py:1153)。反方向的例子是 /api/v1/partners——32 条路由,是唯一整体挂 require_admin 的业务 router(main.py:464-466);multi-user 管理端那 5 条同样全部 Depends(require_admin)(deeptutor/multi_user/router.py:118、135、141、170、226)。
第二道是用户级授权,也就是 grant。这一层的完整字段与三态语义我们另有一篇专门讲,这里只取和”能不能调”直接相关的两条:MCP 工具与 CLI apps 对非 admin 是 deny-by-default——None 即无权限,必须管理员显式点名;exec_enabled 是三态覆盖,且 True 只在 sandbox 能做 SYSTEM 级隔离时才被采纳(deeptutor/multi_user/grants.py:28-43)。这两项对应的是本机上执行外部程序的能力,不是纯读接口,默认拒绝这个取向值得留意。另外 validate_grant() 会直接拒绝 grant 里出现秘密与路径字段,禁用键集合是 {"api_key","secret","password","token","path","base_url"} 外加任何以 _key 结尾的键(grants.py:115-133)。
第三道是每轮派发时的过滤,执行点写在 deeptutor/multi_user/tool_access.py:14-29 的 docstring 里:allowed_optional_tools 由 turn_runtime 每轮过滤 tools 载荷、tools router 过滤列表;allowed_mcp_tools 与调用方的 mcp_tools_filter 求交之后再建 deferred-tool loader;allowed_cli_apps 与账号自身偏好求交。partner 授权则是只读性质,未分配时直接 403,错误串是 "Partner is not assigned to you"(deeptutor/multi_user/partner_access.py:1-13、37-47)。
三道闸的实际含义是:只看某个端点有没有 require_auth,判断不出一个用户到底能不能触发某个工具调用。
六、一处刻意的 fail-closed,和默认回退正好相反
/api/outputs 那个产物下载端点走的是另一套取向:拿不到请求级 user 就直接 404,而不是回退到 admin workspace,注释说这是为了避免鉴权回归导致管理员产物外泄(deeptutor/api/routers/outputs.py:19-30)。
把它和第三节那条 get_current_user() 回退 local_admin_user() 放在一起看,同一个仓库里存在两种相反的取向:通用取用户处是”取不到就回退本地 admin”,产物下载处是”取不到就拒绝”。两处位置都已给出,你可以自己打开对照。差异到这里为止,我们不推断这两处为什么不同,也不据此评价设计。
七、前端那一层不是鉴权,别把它当权限
web/proxy.ts 这个 Next.js middleware 里有一个 auth gate,但它的性质在注释里写得很清楚:对 cookie 的校验是不验签的前线快速判断,把 token 分成 missing / malformed / expired / valid 四态,真正的验证仍由后端每次 API 调用做(web/lib/proxy-policy.ts:48-74)。而且这个 gate 只在多用户模式下工作,还放行 /login、/register、/_next、/favicon 以及一批静态资源后缀——注释说这条豁免是为修 issue #599:登录后 logo 与 banner 变裂图,因为 Next 图片优化器的服务端回环请求不带 auth cookie(proxy-policy.ts:26-46、web/proxy.ts:61-66)。
还有两处细节对排查有用:cookie 名 dt_token 在前后端两处各自写了一遍同样的字面量(web/lib/proxy-policy.ts:10、deeptutor/api/routers/auth.py:59),改一处不会自动同步另一处;auth 到底开没开不走构建期环境变量——DEEPTUTOR_AUTH_ENABLED 没有 NEXT_PUBLIC_ 前缀,不会被内联进浏览器 bundle,前端靠 fetchAuthStatus() 在运行时向后端问,默认值取 false,以免默认无鉴权的部署被偶发 401 弹去登录页(web/lib/api.ts:52-66)。前端 apiFetch 统一带 credentials: "include",401 且运行时得知 auth 开启时才跳 /login,skipAuthRedirect 留给登录/注册场景内联处理(web/lib/api.ts:69-92)。
八、仓库自己标出来的两处边界
按纪律,仓库自述的未完成状态要照实记:
- PocketBase 模式目前是单用户 only。
deeptutor/multi_user/__init__.py:13-18写明 PocketBase 的userscollection 默认没有role字段,所有登录都解析成role="user",因而产生不出 admin;且sessions/messages/turns的查询没有按user_id过滤。docstring 要求把 PocketBase 部署当作单用户看待,直到 schema 与查询被更新。 - 首个注册用户提权 admin 存在多 worker 竞态。 这段用
threading.Lock串行化,但 docstring 自己承认多 worker 部署仍会 race,必须依赖外部用户存储(deeptutor/multi_user/identity.py:20-25)。
顺带记两个身份文件位置,排查时用得上:data/system/auth/users.json 与 data/system/auth/auth_secret,另有两个 legacy 路径 data/user/auth_users.json、data/user/auth_secret(identity.py:27-31)。data/system 这棵树在 deeptutor/multi_user/paths.py:4-11 里被注明”从不挂载进 sandbox runner”。
九、你可以照着核的六步
- 打开
deeptutor/api/routers/auth.py:255-262,读那段 docstring,确认async def的理由与 issue #481 的指名;再翻deeptutor/multi_user/context.py:128-129,确认未设置时回退的是local_admin_user()。 - 打开
auth.py:190-196,确认不用HTTPBearer的理由是 WebSocket 依赖注入不到Request。 - 打开
auth.py:294-320,确认ws_require_auth()必须在ws.accept()之前、以及失败关闭码是4001。 - 打开
auth.py:237-283与327-352,确认AUTH_ENABLED=false时两个依赖各自的退化行为;再回deeptutor/api/main.py:79-99看 CORS 那条 regex。 - 打开
deeptutor/api/routers/outputs.py:19-30,确认那处是 404 而不是回退。 - 把
web/lib/proxy-policy.ts:10与auth.py:59两处 cookie 名字面量并排比一遍,确认它们是各写各的同一个串。
需要说明的边界:本文只读了 auth.py 的关键行段、services/auth.py 的算法与开关常量、以及前端 middleware 的策略文件,JWT 载荷的具体字段与 PocketBase 分支的实现没有逐行核实;后端 322 个路由装饰器我们只统计了数量与挂载 prefix,没有逐条拼出完整 URL 表,因此”某个端点具体校验了什么”多数未核实。上面出现的所有默认值都是源码中的默认配置,不是运行结果的保证。
本文依据 DeepTutor 官方仓库(github.com/HKUDS/DeepTutor)的 README、AGENTS.md、
pyproject.toml 与 deeptutor/ 下的源码整理,核对日 2026-08-10,对应仓库快照 456f9c2(版本 1.5.11)。
本文内容为仓库源码与文档口径,我们没有安装、部署或运行过该项目,也没有调用过其中任何一个模型 API,
因此不涉及生成质量、响应速度与教学效果的任何描述。
参数与默认值随版本变动,请以仓库最新代码与 --help 的实际输出为准。
安全相关做法请结合自身环境评估,本文不构成安全方案建议。