Squashes 25 PR commits onto current main. AppConfig becomes a pure value object with no ambient lookup. Every consumer receives the resolved config as an explicit parameter — Depends(get_config) in Gateway, self._app_config in DeerFlowClient, runtime.context.app_config in agent runs, AppConfig.from_file() at the LangGraph Server registration boundary. Phase 1 — frozen data + typed context - All config models (AppConfig, MemoryConfig, DatabaseConfig, …) become frozen=True; no sub-module globals. - AppConfig.from_file() is pure (no side-effect singleton loaders). - Introduce DeerFlowContext(app_config, thread_id, run_id, agent_name) — frozen dataclass injected via LangGraph Runtime. - Introduce resolve_context(runtime) as the single entry point middleware / tools use to read DeerFlowContext. Phase 2 — pure explicit parameter passing - Gateway: app.state.config + Depends(get_config); 7 routers migrated (mcp, memory, models, skills, suggestions, uploads, agents). - DeerFlowClient: __init__(config=...) captures config locally. - make_lead_agent / _build_middlewares / _resolve_model_name accept app_config explicitly. - RunContext.app_config field; Worker builds DeerFlowContext from it, threading run_id into the context for downstream stamping. - Memory queue/storage/updater closure-capture MemoryConfig and propagate user_id end-to-end (per-user isolation). - Sandbox/skills/community/factories/tools thread app_config. - resolve_context() rejects non-typed runtime.context. - Test suite migrated off AppConfig.current() monkey-patches. - AppConfig.current() classmethod deleted. Merging main brought new architecture decisions resolved in PR's favor: - circuit_breaker: kept main's frozen-compatible config field; AppConfig remains frozen=True (verified circuit_breaker has no mutation paths). - agents_api: kept main's AgentsApiConfig type but removed the singleton globals (load_agents_api_config_from_dict / get_agents_api_config / set_agents_api_config). 8 routes in agents.py now read via Depends(get_config). - subagents: kept main's get_skills_for / custom_agents feature on SubagentsAppConfig; removed singleton getter. registry.py now reads app_config.subagents directly. - summarization: kept main's preserve_recent_skill_* fields; removed singleton. - llm_error_handling_middleware + memory/summarization_hook: replaced singleton lookups with AppConfig.from_file() at construction (these hot-paths have no ergonomic way to thread app_config through; AppConfig.from_file is a pure load). - worker.py + thread_data_middleware.py: DeerFlowContext.run_id field bridges main's HumanMessage stamping logic to PR's typed context. Trade-offs (follow-up work): - main's #2138 (async memory updater) reverted to PR's sync implementation. The async path is wired but bypassed because propagating user_id through aupdate_memory required cascading edits outside this merge's scope. - tests/test_subagent_skills_config.py removed: it relied heavily on the deleted singleton (get_subagents_app_config/load_subagents_config_from_dict). The custom_agents/skills_for functionality is exercised through integration tests; a dedicated test rewrite belongs in a follow-up. Verification: backend test suite — 2560 passed, 4 skipped, 84 failures. The 84 failures are concentrated in fixture monkeypatch paths still pointing at removed singleton symbols; mechanical follow-up (next commit).
53 KiB
Auth 模块测试计划
测试矩阵
| 模式 | 启动命令 | Auth 层 | 端口 |
|---|---|---|---|
| 标准模式 | make dev |
Gateway AuthMiddleware + LangGraph auth | 2026 (nginx) |
| Gateway 模式 | make dev-pro |
Gateway AuthMiddleware(全量) | 2026 (nginx) |
| 直连 Gateway | cd backend && make gateway |
Gateway AuthMiddleware | 8001 |
| 直连 LangGraph | cd backend && make dev |
LangGraph auth | 2024 |
每种模式下都需执行以下测试。
一、环境准备
1.1 首次启动(干净数据库)
# 清除已有数据
rm -f backend/.deer-flow/users.db
# 选择模式启动
make dev # 标准模式
# 或
make dev-pro # Gateway 模式
验证点:
- 控制台输出 admin 邮箱和随机密码
- 密码格式为
secrets.token_urlsafe(16)的 22 字符字符串 - 邮箱为
admin@deerflow.dev - 提示
Change it after login: Settings -> Account
1.2 非首次启动
# 不清除数据库,直接启动
make dev
验证点:
- 控制台不输出密码
- 如果 admin 仍
needs_setup=True,控制台有 warning 提示
1.3 环境变量配置
| 变量 | 验证 |
|---|---|
AUTH_JWT_SECRET 未设 |
启动时 warning,自动生成临时密钥 |
AUTH_JWT_SECRET 已设 |
无 warning,重启后 session 保持 |
二、接口流程测试
以下用
BASE=http://localhost:2026为例。标准模式和 Gateway 模式都用此地址。 直连测试替换为对应端口。CSRF token 提取:多处用到从 cookie jar 提取 CSRF token,统一使用:
CSRF=$(python3 -c " import http.cookiejar cj = http.cookiejar.MozillaCookieJar('cookies.txt'); cj.load() print(next(c.value for c in cj if c.name == 'csrf_token')) ")或简写(多数场景够用):
CSRF=$(grep csrf_token cookies.txt | awk '{print $NF}')
2.1 注册 + 登录 + 会话
TC-API-01: Setup 状态查询
curl -s $BASE/api/v1/auth/setup-status | jq .
预期: 返回 {"needs_setup": false}(admin 在启动时已自动创建,count_users() > 0)。仅在启动完成前的极短窗口内可能返回 true。
TC-API-02: Admin 首次登录
curl -s -X POST $BASE/api/v1/auth/login/local \
-d "username=admin@deerflow.dev&password=<控制台密码>" \
-c cookies.txt | jq .
预期:
- 状态码 200
- Body:
{"expires_in": 604800, "needs_setup": true} cookies.txt包含access_token(HttpOnly)和csrf_token(非 HttpOnly)
TC-API-03: 获取当前用户
curl -s $BASE/api/v1/auth/me -b cookies.txt | jq .
预期: {"id": "...", "email": "admin@deerflow.dev", "system_role": "admin", "needs_setup": true}
TC-API-04: Setup 流程(改邮箱 + 改密码)
CSRF=$(grep csrf_token cookies.txt | awk '{print $NF}')
curl -s -X POST $BASE/api/v1/auth/change-password \
-b cookies.txt \
-H "Content-Type: application/json" \
-H "X-CSRF-Token: $CSRF" \
-d '{"current_password":"<控制台密码>","new_password":"NewPass123!","new_email":"admin@example.com"}' | jq .
预期:
- 状态码 200
{"message": "Password changed successfully"}- 再调
/auth/me邮箱变为admin@example.com,needs_setup变为false
TC-API-05: 普通用户注册
curl -s -X POST $BASE/api/v1/auth/register \
-H "Content-Type: application/json" \
-d '{"email":"user1@example.com","password":"UserPass1!"}' \
-c user_cookies.txt | jq .
预期: 状态码 201,system_role 为 "user",自动登录(cookie 已设)
TC-API-06: 登出
curl -s -X POST $BASE/api/v1/auth/logout -b cookies.txt | jq .
预期: {"message": "Successfully logged out"},后续用 cookies.txt 访问 /auth/me 返回 401
2.2 多租户隔离
TC-API-07: 用户 A 创建 Thread
# 以 user1 登录
curl -s -X POST $BASE/api/v1/auth/login/local \
-d "username=user1@example.com&password=UserPass1!" \
-c user1.txt
CSRF1=$(grep csrf_token user1.txt | awk '{print $NF}')
# 创建 thread
curl -s -X POST $BASE/api/threads \
-b user1.txt \
-H "Content-Type: application/json" \
-H "X-CSRF-Token: $CSRF1" \
-d '{"metadata":{}}' | jq .thread_id
# 记录 THREAD_ID
TC-API-08: 用户 B 无法访问用户 A 的 Thread
# 注册并登录 user2
curl -s -X POST $BASE/api/v1/auth/register \
-H "Content-Type: application/json" \
-d '{"email":"user2@example.com","password":"UserPass2!"}' \
-c user2.txt
# 尝试访问 user1 的 thread
curl -s $BASE/api/threads/$THREAD_ID -b user2.txt
预期: 状态码 404(不是 403,避免泄露 thread 存在性)
TC-API-09: 用户 B 搜索 Thread 看不到用户 A 的
CSRF2=$(grep csrf_token user2.txt | awk '{print $NF}')
curl -s -X POST $BASE/api/threads/search \
-b user2.txt \
-H "Content-Type: application/json" \
-H "X-CSRF-Token: $CSRF2" \
-d '{}' | jq length
预期: 返回 0 或仅包含 user2 自己的 thread
2.3 标准模式 LangGraph Server 隔离
仅在标准模式下测试。Gateway 模式不跑 LangGraph Server。
TC-API-10: LangGraph 端点需要 cookie
# 不带 cookie 访问 LangGraph 接口
curl -s -w "%{http_code}" $BASE/api/langgraph/threads
预期: 401
TC-API-11: LangGraph 带 cookie 可访问
curl -s $BASE/api/langgraph/threads -b user1.txt | jq length
预期: 200,返回 user1 的 thread 列表
TC-API-12: LangGraph 隔离 — 用户只看到自己的
# user2 查 LangGraph threads
curl -s $BASE/api/langgraph/threads -b user2.txt | jq length
预期: 不包含 user1 的 thread
2.4 Token 失效
TC-API-13: 改密码后旧 token 立即失效
# 保存当前 cookie
cp user1.txt user1_old.txt
# 改密码
CSRF1=$(grep csrf_token user1.txt | awk '{print $NF}')
curl -s -X POST $BASE/api/v1/auth/change-password \
-b user1.txt \
-H "Content-Type: application/json" \
-H "X-CSRF-Token: $CSRF1" \
-d '{"current_password":"UserPass1!","new_password":"NewUserPass1!"}' \
-c user1.txt
# 用旧 cookie 访问
curl -s -w "%{http_code}" $BASE/api/v1/auth/me -b user1_old.txt
预期: 401(token_version 不匹配)
TC-API-14: 改密码后新 cookie 可用
curl -s $BASE/api/v1/auth/me -b user1.txt | jq .email
预期: 200,返回用户信息
2.5 错误响应格式
TC-API-15: 结构化错误响应
# 错误密码登录
curl -s -X POST $BASE/api/v1/auth/login/local \
-d "username=admin@example.com&password=wrong" | jq .detail
预期:
{"code": "invalid_credentials", "message": "Incorrect email or password"}
TC-API-16: 重复邮箱注册
curl -s -X POST $BASE/api/v1/auth/register \
-H "Content-Type: application/json" \
-d '{"email":"user1@example.com","password":"AnyPass123"}' -w "\n%{http_code}"
预期: 400,{"code": "email_already_exists", ...}
三、攻击测试
3.1 暴力破解防护
TC-ATK-01: IP 限速
# 连续 6 次错误密码
for i in $(seq 1 6); do
echo "Attempt $i:"
curl -s -X POST $BASE/api/v1/auth/login/local \
-d "username=admin@example.com&password=wrong$i" -w " HTTP %{http_code}\n"
done
预期: 前 5 次返回 401,第 6 次返回 429 "Too many login attempts. Try again later."
TC-ATK-02: 限速后正确密码也被拒
# 紧接上一步
curl -s -X POST $BASE/api/v1/auth/login/local \
-d "username=admin@example.com&password=正确密码" -w " HTTP %{http_code}\n"
预期: 429(锁定 5 分钟)
TC-ATK-03: 成功登录清除限速
# 等待锁定过期后(或重启服务),用正确密码登录
curl -s -X POST $BASE/api/v1/auth/login/local \
-d "username=admin@example.com&password=正确密码" -w " HTTP %{http_code}\n"
预期: 200,计数器重置
3.2 CSRF 防护
TC-ATK-04: 无 CSRF token 的 POST 请求
curl -s -X POST $BASE/api/threads \
-b user1.txt \
-H "Content-Type: application/json" \
-d '{"metadata":{}}' -w "\nHTTP %{http_code}"
预期: 403 "CSRF token missing"
TC-ATK-05: 错误 CSRF token
curl -s -X POST $BASE/api/threads \
-b user1.txt \
-H "Content-Type: application/json" \
-H "X-CSRF-Token: fake-token" \
-d '{"metadata":{}}' -w "\nHTTP %{http_code}"
预期: 403 "CSRF token mismatch"
3.3 Cookie 安全
HTTP 与 HTTPS 行为差异通过
X-Forwarded-Proto: https模拟。 注意: 经 nginx 代理时,nginx 的proxy_set_header X-Forwarded-Proto $scheme会覆盖 客户端发的值($scheme= nginx 监听端口的 scheme),因此 HTTPS 模拟必须直连 Gateway(端口 8001)。 每个 case 需在 login 和 register 两个端点各验证一次。
TC-ATK-06: HTTP 模式 Cookie 属性
# 登录
curl -s -D - -X POST $BASE/api/v1/auth/login/local \
-d "username=admin@example.com&password=正确密码" 2>/dev/null | grep -i set-cookie
预期:
access_token:HttpOnly; Path=/; SameSite=lax,无Secure,无Max-Agecsrf_token:Path=/; SameSite=strict,无HttpOnly(JS 需要读取),无Secure
# 注册
curl -s -D - -X POST $BASE/api/v1/auth/register \
-H "Content-Type: application/json" \
-d '{"email":"cookie-http@example.com","password":"CookieTest1!"}' 2>/dev/null | grep -i set-cookie
预期: 同上
TC-ATK-07: HTTPS 模式 Cookie 属性
必须直连 Gateway(
GW=http://localhost:8001),经 nginx 会被$scheme覆盖。
GW=http://localhost:8001
# 登录(模拟 HTTPS)
curl -s -D - -X POST $GW/api/v1/auth/login/local \
-H "X-Forwarded-Proto: https" \
-d "username=admin@example.com&password=正确密码" 2>/dev/null | grep -i set-cookie
预期:
access_token:HttpOnly; Secure; Path=/; SameSite=lax; Max-Age=604800csrf_token:Secure; Path=/; SameSite=strict,无HttpOnly
# 注册(模拟 HTTPS)
curl -s -D - -X POST $GW/api/v1/auth/register \
-H "Content-Type: application/json" \
-H "X-Forwarded-Proto: https" \
-d '{"email":"cookie-https@example.com","password":"CookieTest1!"}' 2>/dev/null | grep -i set-cookie
预期: 同上
TC-ATK-07a: HTTP/HTTPS 差异对比
直连 Gateway 执行,避免 nginx 覆盖
X-Forwarded-Proto。
GW=http://localhost:8001
for proto in "" "https"; do
HEADER=""
LABEL="HTTP"
if [ -n "$proto" ]; then
HEADER="-H X-Forwarded-Proto:$proto"
LABEL="HTTPS"
fi
echo "=== $LABEL ==="
EMAIL="compare-${LABEL,,}-$(date +%s)@example.com"
curl -s -D - -X POST $GW/api/v1/auth/register \
-H "Content-Type: application/json" $HEADER \
-d "{\"email\":\"$EMAIL\",\"password\":\"Compare1!\"}" 2>/dev/null | grep -i set-cookie | while read line; do
if echo "$line" | grep -q "access_token="; then
echo " access_token:"
echo " HttpOnly: $(echo "$line" | grep -qi httponly && echo YES || echo NO)"
echo " Secure: $(echo "$line" | grep -qi "secure" && echo "$line" | grep -v samesite | grep -qi secure && echo YES || echo NO)"
echo " Max-Age: $(echo "$line" | grep -oi "max-age=[0-9]*" || echo NONE)"
echo " SameSite: $(echo "$line" | grep -oi "samesite=[a-z]*")"
fi
if echo "$line" | grep -q "csrf_token="; then
echo " csrf_token:"
echo " HttpOnly: $(echo "$line" | grep -qi httponly && echo YES || echo NO)"
echo " Secure: $(echo "$line" | grep -qi "secure" && echo "$line" | grep -v samesite | grep -qi secure && echo YES || echo NO)"
echo " SameSite: $(echo "$line" | grep -oi "samesite=[a-z]*")"
fi
done
done
预期对比表:
| 属性 | HTTP access_token | HTTPS access_token | HTTP csrf_token | HTTPS csrf_token |
|---|---|---|---|---|
| HttpOnly | Yes | Yes | No | No |
| Secure | No | Yes | No | Yes |
| SameSite | Lax | Lax | Strict | Strict |
| Max-Age | 无(session cookie) | 604800(7天) | 无 | 无 |
3.4 越权访问
TC-ATK-08: 无 cookie 访问受保护接口
for path in /api/models /api/mcp/config /api/memory /api/skills \
/api/agents /api/channels; do
echo "$path: $(curl -s -w '%{http_code}' -o /dev/null $BASE$path)"
done
预期: 全部 401
TC-ATK-09: 伪造 JWT
# 用不同 secret 签名的 token
FAKE_TOKEN=$(python3 -c "
import jwt
print(jwt.encode({'sub':'admin-id','ver':0,'exp':9999999999}, 'wrong-secret', algorithm='HS256'))
")
curl -s -w "%{http_code}" $BASE/api/v1/auth/me \
--cookie "access_token=$FAKE_TOKEN"
预期: 401(签名验证失败)
TC-ATK-10: 过期 JWT
# 不依赖环境变量,直接用一个已过期的、随机 secret 签名的 token
# 无论 secret 是否匹配,过期 token 都会被拒绝
EXPIRED_TOKEN=$(python3 -c "
import jwt, time
print(jwt.encode({'sub':'x','ver':0,'exp':int(time.time())-100}, 'any-secret-32chars-placeholder!!', algorithm='HS256'))
")
curl -s -w "%{http_code}" -o /dev/null $BASE/api/v1/auth/me \
--cookie "access_token=$EXPIRED_TOKEN"
预期: 401(过期 or 签名不匹配,均被拒绝)
3.5 密码安全
TC-ATK-11: 密码长度不足
curl -s -X POST $BASE/api/v1/auth/register \
-H "Content-Type: application/json" \
-d '{"email":"short@example.com","password":"1234567"}' -w "\nHTTP %{http_code}"
预期: 422(Pydantic validation: min_length=8)
TC-ATK-12: 密码不以明文存储
# 检查数据库
sqlite3 backend/.deer-flow/users.db "SELECT email, password_hash FROM users LIMIT 3;"
预期: password_hash 以 $2b$ 开头(bcrypt 格式)
四、UI 操作测试
浏览器中操作,验证前后端联动。
4.1 首次登录流程
TC-UI-01: 访问首页跳转登录
- 打开
http://localhost:2026/workspace - 预期: 自动跳转到
/login
TC-UI-02: Login 页面
- 输入 admin 邮箱和控制台密码
- 点击 Login
- 预期: 跳转到
/setup(因为needs_setup=true)
TC-UI-03: Setup 页面
- 输入新邮箱、控制台密码(current)、新密码、确认密码
- 点击 Complete Setup
- 预期: 跳转到
/workspace - 刷新页面不跳回
/setup
TC-UI-04: Setup 密码不匹配
- 新密码和确认密码不一致
- 点击 Complete Setup
- 预期: 显示 "Passwords do not match" 错误
4.2 日常使用
TC-UI-05: 创建对话
- 在 workspace 发送一条消息
- 预期: 左侧栏出现新 thread
TC-UI-06: 对话持久化
- 创建对话后刷新页面
- 预期: 对话列表和内容仍然存在
TC-UI-07: 登出
- 点击头像 → Logout
- 预期: 跳转到首页
/ - 直接访问
/workspace→ 跳转到/login
4.3 多用户隔离
TC-UI-08: 用户 A 看不到用户 B 的对话
- 用户 A 在浏览器 1 登录,创建一个对话并发消息
- 用户 B 在浏览器 2(或隐身窗口)注册并登录
- 预期: 用户 B 的 workspace 左侧栏为空,看不到用户 A 的对话
TC-UI-09: 直接 URL 访问他人 Thread
- 复制用户 A 的 thread URL
- 在用户 B 的浏览器中访问
- 预期: 404 或空白页,不显示对话内容
4.4 Session 管理
TC-UI-10: Tab 切换 Session 检查
- 登录 workspace
- 切换到其他 tab 等待 60+ 秒
- 切回 workspace tab
- 预期: 静默检查 session,页面正常(控制台无 401 刷屏)
TC-UI-11: Session 过期后 Tab 切回
- 登录 workspace
- 在另一个 tab 改密码(使当前 session 失效)
- 切回 workspace tab
- 预期: 自动跳转到
/login
TC-UI-12: 改密码后 Settings 页面
- 进入 Settings → Account
- 修改密码
- 预期: 成功提示,页面不需要重新登录(cookie 已自动更新)
4.5 注册流程
TC-UI-13: 从登录页跳转注册
- 在
/login页面点击注册链接 - 输入邮箱和密码
- 预期: 注册成功后自动跳转
/workspace
TC-UI-14: 重复邮箱注册
- 用已注册的邮箱尝试注册
- 预期: 显示 "Email already registered" 错误
4.6 密码重置(CLI)
TC-UI-15: reset_admin 后重新登录
- 执行
cd backend && python -m app.gateway.auth.reset_admin - 使用新密码登录
- 预期: 跳转到
/setup页面(needs_setup被重置为 true) - 旧 session 已失效
五、升级测试
模拟从无 auth 版本(main 分支)升级到 auth 版本(feat/rfc-001-auth-module)。
5.1 准备旧版数据
# 1. 切到 main 分支,启动服务
git stash && git checkout main
make dev
# 2. 创建一些对话数据(无 auth,直接访问)
curl -s -X POST http://localhost:2026/api/langgraph/threads \
-H "Content-Type: application/json" \
-d '{"metadata":{"title":"old-thread-1"}}' | jq .thread_id
curl -s -X POST http://localhost:2026/api/langgraph/threads \
-H "Content-Type: application/json" \
-d '{"metadata":{"title":"old-thread-2"}}' | jq .thread_id
# 3. 记录 thread 数量
curl -s http://localhost:2026/api/langgraph/threads | jq length
# 预期: 2+
# 4. 停止服务
make stop
5.2 升级并启动
# 5. 切到 auth 分支
git checkout feat/rfc-001-auth-module && git stash pop
make install
make dev
TC-UPG-01: 首次启动创建 admin
预期:
- 控制台输出 admin 邮箱(
admin@deerflow.dev)和随机密码 - 无报错,正常启动
TC-UPG-02: 旧 Thread 迁移到 admin
# 登录 admin
curl -s -X POST http://localhost:2026/api/v1/auth/login/local \
-d "username=admin@deerflow.dev&password=<控制台密码>" \
-c cookies.txt
# 查看 thread 列表
CSRF=$(grep csrf_token cookies.txt | awk '{print $NF}')
curl -s -X POST http://localhost:2026/api/threads/search \
-b cookies.txt \
-H "Content-Type: application/json" \
-H "X-CSRF-Token: $CSRF" \
-d '{}' | jq length
预期:
- 返回的 thread 数量 ≥ 旧版创建的数量
- 控制台日志有
Migrated N orphaned thread(s) to admin - 每个 thread 的
metadata.owner_id都已被设为 admin 的 ID
TC-UPG-03: 旧 Thread 内容完整
# 检查某个旧 thread 的内容
curl -s http://localhost:2026/api/threads/<old-thread-id> \
-b cookies.txt | jq .metadata
预期:
metadata.title保留原值(如old-thread-1)metadata.owner_id已填充
TC-UPG-04: 新用户看不到旧 Thread
# 注册新用户
curl -s -X POST http://localhost:2026/api/v1/auth/register \
-H "Content-Type: application/json" \
-d '{"email":"newuser@example.com","password":"NewPass123!"}' \
-c newuser.txt
CSRF2=$(grep csrf_token newuser.txt | awk '{print $NF}')
curl -s -X POST http://localhost:2026/api/threads/search \
-b newuser.txt \
-H "Content-Type: application/json" \
-H "X-CSRF-Token: $CSRF2" \
-d '{}' | jq length
预期: 返回 0(旧 thread 属于 admin,新用户不可见)
5.3 数据库 Schema 兼容
TC-UPG-05: 无 users.db 时自动创建
ls -la backend/.deer-flow/users.db
预期: 文件存在,sqlite3 可查到 users 表含 needs_setup、token_version 列
TC-UPG-06: users.db WAL 模式
sqlite3 backend/.deer-flow/users.db "PRAGMA journal_mode;"
预期: 返回 wal
5.4 配置兼容
TC-UPG-07: 无 AUTH_JWT_SECRET 的旧 .env 文件
# 确认 .env 中没有 AUTH_JWT_SECRET
grep AUTH_JWT_SECRET backend/.env || echo "NOT SET"
预期:
- 启动时 warning:
AUTH_JWT_SECRET is not set — using auto-generated ephemeral secret - 服务正常可用
- 重启后旧 session 失效(临时密钥变了)
TC-UPG-08: 旧 config.yaml 无 auth 相关配置
# 检查 config.yaml 没有 auth 段
grep -c "auth" config.yaml || echo "0"
预期: auth 模块不依赖 config.yaml(配置走环境变量),旧 config.yaml 不影响启动
5.5 前端兼容
TC-UPG-09: 旧前端缓存
- 用旧版前端的浏览器缓存访问升级后的服务
- 预期: 被 AuthMiddleware 拦截返回 401(旧前端无 cookie),页面自然刷新后加载新前端
TC-UPG-10: 书签 URL
- 用升级前保存的 workspace URL(如
localhost:2026/workspace/chats/xxx)直接访问 - 预期: 跳转到
/login,登录后跳回原 URL(?next=参数)
5.6 降级回滚
TC-UPG-11: 回退到 main 分支
make stop
git checkout main
make dev
预期:
- 服务正常启动(忽略
users.db,无 auth 相关代码不报错) - 旧对话数据仍然可访问
users.db文件残留但不影响运行
TC-UPG-12: 再次升级到 auth 分支
make stop
git checkout feat/rfc-001-auth-module
make dev
预期:
- 识别已有
users.db,不重新创建 admin - 旧的 admin 账号仍可登录(如果回退期间未删
users.db)
5.7 休眠 Admin(初始密码未使用/未更改)
首次启动生成 admin + 随机密码,但运维未登录、未改密码。 密码只在首次启动的控制台闪过一次,后续启动不再显示。
TC-UPG-13: 重启后自动重置密码并打印
# 首次启动,记录密码
rm -f backend/.deer-flow/users.db
make dev
# 控制台输出密码 P0,不登录
make stop
# 隔了几天,再次启动
make dev
# 控制台输出新密码 P1
预期:
- 控制台输出
Admin account setup incomplete — password reset - 输出新密码 P1(P0 已失效)
- 用 P1 可以登录,P0 不可以
- 登录后
needs_setup=true,跳转/setup token_version递增(旧 session 如有也失效)
TC-UPG-14: 密码丢失 — 无需 CLI,重启即可
# 忘记了控制台密码 → 直接重启服务
make stop && make dev
# 控制台自动输出新密码
预期:
- 无需
reset_admin,重启服务即可拿到新密码 reset_adminCLI 仍然可用作手动备选方案
TC-UPG-15: 休眠 admin 期间普通用户注册
# admin 存在但从未登录,普通用户先注册
curl -s -X POST $BASE/api/v1/auth/register \
-H "Content-Type: application/json" \
-d '{"email":"earlybird@example.com","password":"EarlyPass1!"}' \
-c early.txt -w "\nHTTP %{http_code}"
预期:
- 注册成功(201),角色为
user - 无法提权为 admin
- 普通用户的数据与 admin 隔离
TC-UPG-16: 休眠 admin 不影响后续操作
# 普通用户正常创建 thread、发消息
CSRF=$(grep csrf_token early.txt | awk '{print $NF}')
curl -s -X POST $BASE/api/threads \
-b early.txt \
-H "Content-Type: application/json" \
-H "X-CSRF-Token: $CSRF" \
-d '{"metadata":{}}' | jq .thread_id
预期: 正常创建,不受休眠 admin 影响
TC-UPG-17: 休眠 admin 最终完成 Setup
# 运维终于登录
curl -s -X POST $BASE/api/v1/auth/login/local \
-d "username=admin@deerflow.dev&password=<P0或P1>" \
-c admin.txt | jq .needs_setup
# 预期: true
# 完成 setup
CSRF=$(grep csrf_token admin.txt | awk '{print $NF}')
curl -s -X POST $BASE/api/v1/auth/change-password \
-b admin.txt \
-H "Content-Type: application/json" \
-H "X-CSRF-Token: $CSRF" \
-d '{"current_password":"<密码>","new_password":"AdminFinal1!","new_email":"admin@real.com"}' \
-c admin.txt
# 验证
curl -s $BASE/api/v1/auth/me -b admin.txt | jq '{email, needs_setup}'
预期:
email变为admin@real.comneeds_setup变为false- 后续重启控制台不再有 warning
TC-UPG-18: 长期未用后 JWT 密钥轮换
# 场景:admin 未登录期间,运维更换了 AUTH_JWT_SECRET
# 1. 首次启动用自动生成的临时密钥
# 2. 某天运维在 .env 设置了固定密钥
echo "AUTH_JWT_SECRET=$(python3 -c 'import secrets; print(secrets.token_urlsafe(32))')" >> .env
make stop && make dev
预期:
- 服务正常启动
- 旧密码仍可登录(密码存在 DB,与 JWT 密钥无关)
- 旧的 JWT token 失效(密钥变了签名不匹配)— 但因为从未登录过也没有旧 token
六、可重入测试
验证 auth 模块在重复操作、并发、中断恢复等场景下行为正确,无竞态条件。
6.1 启动可重入
TC-REENT-01: 连续重启不重复创建 admin
# 连续启动 3 次(daemon 模式,避免前台阻塞)
for i in 1 2 3; do
make dev-daemon && sleep 10 && make stop
done
# 检查 admin 数量
sqlite3 backend/.deer-flow/users.db \
"SELECT COUNT(*) FROM users WHERE system_role='admin';"
预期: 始终为 1。不会因重启创建多个 admin。
TC-REENT-02: 多进程同时启动
# 模拟两个 gateway 进程同时启动(竞争 admin 创建)
cd backend
PYTHONPATH=. uv run python -c "
import asyncio
from app.gateway.app import create_app, _ensure_admin_user
async def boot():
app = create_app()
# 模拟两个并发 ensure_admin
await asyncio.gather(
_ensure_admin_user(app),
_ensure_admin_user(app),
)
asyncio.run(boot())
" 2>&1 | grep -i "admin\|error\|duplicate"
预期:
- 不报错(SQLite UNIQUE 约束捕获竞争,第二个静默跳过)
- 最终只有 1 个 admin
TC-REENT-03: Thread 迁移幂等
# 连续调用 _migrate_orphaned_threads 两次
# 第二次应无 thread 需要迁移(已有 user_id)
预期: 第二次 migrated = 0,无副作用
6.2 登录可重入
TC-REENT-04: 重复登录获取新 cookie
# 同一用户连续登录 3 次
for i in 1 2 3; do
curl -s -X POST $BASE/api/v1/auth/login/local \
-d "username=admin@example.com&password=正确密码" \
-c "cookies_$i.txt" -o /dev/null
done
# 三个 cookie 都有效
for i in 1 2 3; do
echo "Cookie $i: $(curl -s -w '%{http_code}' -o /dev/null $BASE/api/v1/auth/me -b cookies_$i.txt)"
done
预期: 三个 cookie 都返回 200(未改密码,token_version 相同,多 session 共存)
TC-REENT-05: 登录-登出-登录
# 登录
curl -s -X POST $BASE/api/v1/auth/login/local \
-d "username=admin@example.com&password=正确密码" \
-c cookies.txt -o /dev/null
# 登出
curl -s -X POST $BASE/api/v1/auth/logout -b cookies.txt -o /dev/null
# 再次登录
curl -s -X POST $BASE/api/v1/auth/login/local \
-d "username=admin@example.com&password=正确密码" \
-c cookies.txt
curl -s -w "%{http_code}" $BASE/api/v1/auth/me -b cookies.txt
预期: 200。登出→再登录流程无状态残留。
6.3 改密码可重入
TC-REENT-06: 连续两次改密码
CSRF=$(grep csrf_token cookies.txt | awk '{print $NF}')
# 第一次改密码
curl -s -X POST $BASE/api/v1/auth/change-password \
-b cookies.txt \
-H "Content-Type: application/json" \
-H "X-CSRF-Token: $CSRF" \
-d '{"current_password":"Pass1","new_password":"Pass2"}' \
-c cookies.txt
# 用新 cookie 的 CSRF 再改一次
CSRF=$(grep csrf_token cookies.txt | awk '{print $NF}')
curl -s -X POST $BASE/api/v1/auth/change-password \
-b cookies.txt \
-H "Content-Type: application/json" \
-H "X-CSRF-Token: $CSRF" \
-d '{"current_password":"Pass2","new_password":"Pass3"}' \
-c cookies.txt
curl -s -w "%{http_code}" $BASE/api/v1/auth/me -b cookies.txt
预期:
- 两次改密码都成功
- 最终密码为 Pass3
token_version递增两次(+2)- 最新 cookie 有效
TC-REENT-07: 改密码后旧 cookie 全部失效
# 保存三个时间点的 cookie
# t1: 初始登录 → cookies_t1.txt
# t2: 第一次改密码后 → cookies_t2.txt
# t3: 第二次改密码后 → cookies_t3.txt
# 用 t1 和 t2 的 cookie 访问
curl -s -w "%{http_code}" $BASE/api/v1/auth/me -b cookies_t1.txt # 预期 401
curl -s -w "%{http_code}" $BASE/api/v1/auth/me -b cookies_t2.txt # 预期 401
curl -s -w "%{http_code}" $BASE/api/v1/auth/me -b cookies_t3.txt # 预期 200
预期: 只有最新的 cookie 有效,历史 cookie 因 token_version 不匹配全部 401
6.4 注册可重入
TC-REENT-08: 同一邮箱并发注册
# 并发发送两个相同邮箱的注册请求
curl -s -X POST $BASE/api/v1/auth/register \
-H "Content-Type: application/json" \
-d '{"email":"race@example.com","password":"RacePass1!"}' &
curl -s -X POST $BASE/api/v1/auth/register \
-H "Content-Type: application/json" \
-d '{"email":"race@example.com","password":"RacePass1!"}' &
wait
# 检查用户数
sqlite3 backend/.deer-flow/users.db \
"SELECT COUNT(*) FROM users WHERE email='race@example.com';"
预期:
- 一个成功(201),一个失败(400
email_already_exists) - 数据库中只有 1 条记录(UNIQUE 约束保护)
6.5 Rate Limiter 可重入
TC-REENT-09: 限速过期后重新计数
# 触发锁定(5 次错误)
for i in $(seq 1 5); do
curl -s -o /dev/null -X POST $BASE/api/v1/auth/login/local \
-d "username=admin@example.com&password=wrong"
done
# 确认被锁定
curl -s -w "%{http_code}" -o /dev/null -X POST $BASE/api/v1/auth/login/local \
-d "username=admin@example.com&password=wrong"
# 预期: 429
# 等待锁定过期(5 分钟)或重启服务清除内存计数器
make stop && make dev
# 重新尝试 — 计数器应已重置
curl -s -w "%{http_code}" -o /dev/null -X POST $BASE/api/v1/auth/login/local \
-d "username=admin@example.com&password=wrong"
# 预期: 401(不是 429)
预期: 锁定过期后恢复正常限速(从 0 开始计数),而非累积
TC-REENT-10: 成功登录重置计数后再次失败
# 3 次失败
for i in $(seq 1 3); do
curl -s -o /dev/null -X POST $BASE/api/v1/auth/login/local \
-d "username=admin@example.com&password=wrong"
done
# 1 次成功(重置计数)
curl -s -o /dev/null -X POST $BASE/api/v1/auth/login/local \
-d "username=admin@example.com&password=正确密码"
# 再 4 次失败(从 0 重新计数,未达阈值 5)
for i in $(seq 1 4); do
curl -s -w "attempt $i: %{http_code}\n" -o /dev/null -X POST $BASE/api/v1/auth/login/local \
-d "username=admin@example.com&password=wrong"
done
预期: 4 次全部返回 401(未锁定),因为成功登录已重置计数器
6.6 CSRF Token 可重入
TC-REENT-11: 登录后多次 POST 使用同一 CSRF token
CSRF=$(grep csrf_token cookies.txt | awk '{print $NF}')
# 同一 CSRF token 多次使用
for i in 1 2 3; do
echo "Request $i: $(curl -s -w '%{http_code}' -o /dev/null \
-X POST $BASE/api/threads \
-b cookies.txt \
-H 'Content-Type: application/json' \
-H "X-CSRF-Token: $CSRF" \
-d '{"metadata":{}}')"
done
预期: 三次都成功(CSRF token 是 Double Submit Cookie,不是一次性 nonce)
6.7 Thread 操作可重入
TC-REENT-12: 重复删除同一 Thread
CSRF=$(grep csrf_token cookies.txt | awk '{print $NF}')
# 创建 thread
TID=$(curl -s -X POST $BASE/api/threads \
-b cookies.txt \
-H "Content-Type: application/json" \
-H "X-CSRF-Token: $CSRF" \
-d '{"metadata":{}}' | jq -r .thread_id)
# 第一次删除
curl -s -w "%{http_code}" -X DELETE "$BASE/api/threads/$TID" \
-b cookies.txt -H "X-CSRF-Token: $CSRF"
# 预期: 200
# 第二次删除(幂等)
curl -s -w "%{http_code}" -X DELETE "$BASE/api/threads/$TID" \
-b cookies.txt -H "X-CSRF-Token: $CSRF"
预期: 第二次返回 200 或 404,不报 500
6.8 reset_admin 可重入
TC-REENT-13: 连续两次 reset_admin
cd backend
python -m app.gateway.auth.reset_admin
# 记录密码 P1
python -m app.gateway.auth.reset_admin
# 记录密码 P2
预期:
- P1 ≠ P2(每次生成新随机密码)
- P1 不可用,只有 P2 有效
token_version递增了 2needs_setup为 True
6.9 Setup 流程可重入
TC-REENT-14: 完成 Setup 后再访问 /setup 页面
- 完成 admin setup(改邮箱 + 改密码)
- 直接访问
/setup - 预期: 应跳转到
/workspace(needs_setup已为 false,SSR guard 不会返回needs_setuptag)
TC-REENT-15: Setup 中途刷新页面
- 在
/setup页面填写一半 - 刷新页面
- 预期: 仍在
/setup(needs_setup仍为 true),表单清空但不报错
七、模式差异测试
以下用
GW=http://localhost:8001表示直连 Gateway,BASE=http://localhost:2026表示经 nginx。 Gateway 模式启动命令:make dev-pro(或./scripts/serve.sh --dev --gateway)。
7.1 标准模式独有
启动命令:
make dev(或./scripts/serve.sh --dev)
TC-MODE-01: LangGraph Server 独立运行,需 cookie
# 无 cookie 访问 LangGraph
curl -s -w "%{http_code}" -o /dev/null $BASE/api/langgraph/threads/search
# 预期: 403(LangGraph auth handler 拒绝)
TC-MODE-02: LangGraph auth 的 token_version 检查
# 登录拿 cookie
curl -s -X POST $BASE/api/v1/auth/login/local \
-d "username=admin@example.com&password=正确密码" -c cookies.txt
# 改密码(bumps token_version)
CSRF=$(grep csrf_token cookies.txt | awk '{print $NF}')
curl -s -X POST $BASE/api/v1/auth/change-password \
-b cookies.txt -H "Content-Type: application/json" -H "X-CSRF-Token: $CSRF" \
-d '{"current_password":"正确密码","new_password":"NewPass1!"}' -c new_cookies.txt
# 用旧 cookie 访问 LangGraph
curl -s -w "%{http_code}" $BASE/api/langgraph/threads/search -b cookies.txt
# 预期: 403(token_version 不匹配)
# 用新 cookie 访问
CSRF2=$(grep csrf_token new_cookies.txt | awk '{print $NF}')
curl -s -w "%{http_code}" -X POST $BASE/api/langgraph/threads/search \
-b new_cookies.txt -H "Content-Type: application/json" -H "X-CSRF-Token: $CSRF2" -d '{}'
# 预期: 200
TC-MODE-03: LangGraph auth 的 owner filter 隔离
# user1 创建 thread
curl -s -X POST $BASE/api/v1/auth/login/local \
-d "username=user1@example.com&password=UserPass1!" -c u1.txt
CSRF1=$(grep csrf_token u1.txt | awk '{print $NF}')
TID=$(curl -s -X POST $BASE/api/langgraph/threads \
-b u1.txt -H "Content-Type: application/json" -H "X-CSRF-Token: $CSRF1" \
-d '{"metadata":{}}' | python3 -c "import sys,json; print(json.load(sys.stdin)['thread_id'])")
# user2 搜索 — 应看不到 user1 的 thread
curl -s -X POST $BASE/api/v1/auth/login/local \
-d "username=user2@example.com&password=UserPass2!" -c u2.txt
CSRF2=$(grep csrf_token u2.txt | awk '{print $NF}')
curl -s -X POST $BASE/api/langgraph/threads/search \
-b u2.txt -H "Content-Type: application/json" -H "X-CSRF-Token: $CSRF2" -d '{}' | python3 -c "
import sys,json
threads = json.load(sys.stdin)
ids = [t['thread_id'] for t in threads]
assert '$TID' not in ids, 'LEAK: user2 can see user1 thread'
print('OK: user2 sees', len(threads), 'threads, none belong to user1')
"
7.2 Gateway 模式独有
启动命令:
make dev-pro(或./scripts/serve.sh --dev --gateway) 无 LangGraph Server 进程,agent runtime 嵌入 Gateway。
TC-MODE-04: 所有请求经 AuthMiddleware
# 确认 LangGraph Server 未运行
curl -s -w "%{http_code}" -o /dev/null http://localhost:2024/ok
# 预期: 000(连接被拒)
# Gateway API 受保护
curl -s -w "%{http_code}" -o /dev/null $BASE/api/models
# 预期: 401
# LangGraph 兼容路由(rewrite 到 Gateway)也受保护
curl -s -w "%{http_code}" -o /dev/null -X POST $BASE/api/langgraph/threads/search \
-H "Content-Type: application/json" -d '{}'
# 预期: 401
TC-MODE-05: Gateway 模式下完整 auth 流程
# 登录
curl -s -X POST $BASE/api/v1/auth/login/local \
-d "username=admin@example.com&password=正确密码" -c cookies.txt
CSRF=$(grep csrf_token cookies.txt | awk '{print $NF}')
# 创建 thread(走 Gateway 内嵌 runtime)
curl -s -X POST $BASE/api/langgraph/threads \
-b cookies.txt -H "Content-Type: application/json" -H "X-CSRF-Token: $CSRF" \
-d '{"metadata":{}}' | python3 -c "import sys,json; print(json.load(sys.stdin)['thread_id'])"
# 预期: 返回 thread_id
# CSRF 保护(Gateway 模式下 CSRFMiddleware 直接覆盖所有路由)
curl -s -w "%{http_code}" -o /dev/null -X POST $BASE/api/langgraph/threads \
-b cookies.txt -H "Content-Type: application/json" -d '{"metadata":{}}'
# 预期: 403(CSRF token missing)
7.3 直连 Gateway(无 nginx)
启动命令:
cd backend && make gateway(端口 8001) 不经过 nginx,直接测试 Gateway 的 auth 层。
TC-GW-01: AuthMiddleware 保护所有非 public 路由
GW=http://localhost:8001
for path in /api/models /api/mcp/config /api/memory /api/skills \
/api/v1/auth/me /api/v1/auth/change-password; do
echo "$path: $(curl -s -w '%{http_code}' -o /dev/null $GW$path)"
done
# 预期: 全部 401
TC-GW-02: Public 路由不需要 cookie
GW=http://localhost:8001
for path in /health /api/v1/auth/setup-status /api/v1/auth/login/local /api/v1/auth/register; do
echo "$path: $(curl -s -w '%{http_code}' -o /dev/null $GW$path)"
done
# 预期: 200 或 405/422(方法不对但不是 401)
TC-GW-03: 直连 Gateway 注册 + 登录 + CSRF 完整流程
GW=http://localhost:8001
# 注册
curl -s -X POST $GW/api/v1/auth/register \
-H "Content-Type: application/json" \
-d '{"email":"gwtest@example.com","password":"GwTest123!"}' \
-c gw_cookies.txt -w "\nHTTP %{http_code}"
# 预期: 201
# 登录
curl -s -X POST $GW/api/v1/auth/login/local \
-d "username=gwtest@example.com&password=GwTest123!" \
-c gw_cookies.txt -w "\nHTTP %{http_code}"
# 预期: 200
# GET(不需要 CSRF)
curl -s -w "%{http_code}" $GW/api/models -b gw_cookies.txt
# 预期: 200
# POST 无 CSRF
curl -s -w "%{http_code}" -o /dev/null -X POST $GW/api/memory/reload -b gw_cookies.txt
# 预期: 403(CSRF token missing)
# POST 有 CSRF
CSRF=$(grep csrf_token gw_cookies.txt | awk '{print $NF}')
curl -s -w "%{http_code}" -o /dev/null -X POST $GW/api/memory/reload \
-b gw_cookies.txt -H "X-CSRF-Token: $CSRF"
# 预期: 200
TC-GW-04: 直连 Gateway 的 Rate Limiter
GW=http://localhost:8001
# 直连时 request.client.host 是真实 IP(无 nginx 代理),不读 X-Real-IP
for i in $(seq 1 6); do
echo -n "attempt $i: "
curl -s -w "%{http_code}\n" -o /dev/null -X POST $GW/api/v1/auth/login/local \
-d "username=admin@example.com&password=wrong"
done
# 预期: 前 5 次 401,第 6 次 429
TC-GW-05: 直连 Gateway 不受 X-Real-IP 欺骗
GW=http://localhost:8001
# 直连时 client.host 不是 trusted proxy,X-Real-IP 被忽略
for i in $(seq 1 6); do
echo -n "attempt $i (X-Real-IP spoofed): "
curl -s -w "%{http_code}\n" -o /dev/null -X POST $GW/api/v1/auth/login/local \
-H "X-Real-IP: 10.0.0.$i" \
-d "username=admin@example.com&password=wrong"
done
# 预期: 前 5 次 401,第 6 次 429(伪造的 X-Real-IP 无效,所有请求共享真实 IP 的桶)
7.4 Docker 部署
启动命令:
./scripts/deploy.sh(标准)或./scripts/deploy.sh --gateway(Gateway 模式) Docker Compose 文件:docker/docker-compose.yaml前置条件:
.env中设置AUTH_JWT_SECRET(否则每次容器重启 session 全部失效)DEER_FLOW_HOME挂载到宿主机目录(持久化users.db)
TC-DOCKER-01: users.db 通过 volume 持久化
# 启动容器
./scripts/deploy.sh
# 等待启动完成
sleep 15
BASE=http://localhost:2026
# 注册用户
curl -s -X POST $BASE/api/v1/auth/register \
-H "Content-Type: application/json" \
-d '{"email":"docker-test@example.com","password":"DockerTest1!"}' -w "\nHTTP %{http_code}"
# 检查宿主机上的 users.db
ls -la ${DEER_FLOW_HOME:-backend/.deer-flow}/users.db
sqlite3 ${DEER_FLOW_HOME:-backend/.deer-flow}/users.db \
"SELECT email FROM users WHERE email='docker-test@example.com';"
预期: users.db 在宿主机 DEER_FLOW_HOME 目录中,查询可见刚注册的用户。
TC-DOCKER-02: 重启容器后 session 保持
# 登录拿 cookie
curl -s -X POST $BASE/api/v1/auth/login/local \
-d "username=docker-test@example.com&password=DockerTest1!" \
-c docker_cookies.txt -o /dev/null
# 验证 cookie 有效
curl -s -w "%{http_code}" -o /dev/null $BASE/api/v1/auth/me -b docker_cookies.txt
# 预期: 200
# 重启容器(不删 volume)
./scripts/deploy.sh down && ./scripts/deploy.sh
sleep 15
# 用旧 cookie 访问
curl -s -w "%{http_code}" -o /dev/null $BASE/api/v1/auth/me -b docker_cookies.txt
预期:
- 有
AUTH_JWT_SECRET→ 200(session 保持) - 无
AUTH_JWT_SECRET→ 401(每次启动生成新临时密钥,旧 JWT 签名失效)
TC-DOCKER-03: 多 Worker 下 Rate Limiter 独立
# docker-compose.yaml 中 gateway 默认 4 workers
# 每个 worker 有独立的 _login_attempts dict
# 限速可能不精确(请求分散到不同 worker),但不会完全失效
for i in $(seq 1 20); do
echo -n "attempt $i: "
curl -s -w "%{http_code}\n" -o /dev/null -X POST $BASE/api/v1/auth/login/local \
-d "username=docker-test@example.com&password=wrong"
done
预期: 在某个点开始返回 429(每个 worker 独立计数,阈值可能在 5~20 之间触发,取决于负载均衡分布)。
已知限制: In-process rate limiter 不跨 worker 共享。生产环境如需精确限速,需要 Redis 等外部存储。
TC-DOCKER-04: IM 渠道不经过 auth
# IM 渠道(Feishu/Slack/Telegram)在 gateway 容器内部通过 LangGraph SDK 通信
# 不走 nginx,不经过 AuthMiddleware
# 验证方式:检查 gateway 日志中 channel manager 的请求不包含 auth 错误
docker logs deer-flow-gateway 2>&1 | grep -E "ChannelManager|channel" | head -10
预期: 无 auth 相关错误。渠道通过 langgraph-sdk 直连 LangGraph Server(http://langgraph:2024),不走 auth 层。
TC-DOCKER-05: admin 密码写入 0600 凭证文件(不再走日志)
# 凭证文件写在挂载到宿主机的 DEER_FLOW_HOME 下
ls -la ${DEER_FLOW_HOME:-backend/.deer-flow}/admin_initial_credentials.txt
# 预期文件权限: -rw------- (0600)
cat ${DEER_FLOW_HOME:-backend/.deer-flow}/admin_initial_credentials.txt
# 预期内容: email + password 行
# 容器日志只输出文件路径,不输出密码本身
docker logs deer-flow-gateway 2>&1 | grep -E "Credentials written to|Admin account"
# 预期看到: "Credentials written to: /...../admin_initial_credentials.txt (mode 0600)"
# 反向验证: 日志里 NEVER 出现明文密码
docker logs deer-flow-gateway 2>&1 | grep -iE "Password: .{15,}" && echo "FAIL: leaked" || echo "OK: not leaked"
预期:
- 凭证文件存在于
DEER_FLOW_HOME下,权限0600 - 容器日志输出路径(不是密码本身),符合 CodeQL
py/clear-text-logging-sensitive-data规则 grep "Password:"在日志中应当无匹配(旧行为已废弃,simplify pass 移除了日志泄露路径)
TC-DOCKER-06: Gateway 模式 Docker 部署
# Gateway 模式:无 langgraph 容器
./scripts/deploy.sh --gateway
sleep 15
# 确认 langgraph 容器不存在
docker ps --filter name=deer-flow-langgraph --format '{{.Names}}' | wc -l
# 预期: 0
# auth 流程正常
curl -s -w "%{http_code}" -o /dev/null $BASE/api/models
# 预期: 401
curl -s -X POST $BASE/api/v1/auth/login/local \
-d "username=admin@deerflow.dev&password=<日志密码>" \
-c cookies.txt -w "\nHTTP %{http_code}"
# 预期: 200
7.4 补充边界用例
TC-EDGE-01: 格式正确但随机 JWT
RANDOM_JWT=$(python3 -c "
import jwt, time, uuid
print(jwt.encode({'sub':str(uuid.uuid4()),'ver':0,'exp':int(time.time())+3600}, 'wrong-secret-32chars-placeholder!!', algorithm='HS256'))
")
curl -s --cookie "access_token=$RANDOM_JWT" $BASE/api/v1/auth/me | jq .detail
预期: {"code": "token_invalid", "message": "Token error: invalid_signature"}
TC-EDGE-02: 注册时传 system_role=admin
curl -s -X POST $BASE/api/v1/auth/register \
-H "Content-Type: application/json" \
-d '{"email":"hacker@example.com","password":"HackPass1!","system_role":"admin"}' | jq .system_role
预期: "user"(system_role 字段被忽略)
TC-EDGE-03: 并发改密码
# 注册用户,登录两个 session
curl -s -X POST $BASE/api/v1/auth/register \
-H "Content-Type: application/json" \
-d '{"email":"edge03@example.com","password":"EdgePass3!"}' -o /dev/null
curl -s -X POST $BASE/api/v1/auth/login/local \
-d "username=edge03@example.com&password=EdgePass3!" -c s1.txt -o /dev/null
curl -s -X POST $BASE/api/v1/auth/login/local \
-d "username=edge03@example.com&password=EdgePass3!" -c s2.txt -o /dev/null
CSRF1=$(grep csrf_token s1.txt | awk '{print $NF}')
CSRF2=$(grep csrf_token s2.txt | awk '{print $NF}')
# 并发改密码
curl -s -w "S1: %{http_code}\n" -o /dev/null -X POST $BASE/api/v1/auth/change-password \
-b s1.txt -H "Content-Type: application/json" -H "X-CSRF-Token: $CSRF1" \
-d '{"current_password":"EdgePass3!","new_password":"NewEdge3a!"}' &
curl -s -w "S2: %{http_code}\n" -o /dev/null -X POST $BASE/api/v1/auth/change-password \
-b s2.txt -H "Content-Type: application/json" -H "X-CSRF-Token: $CSRF2" \
-d '{"current_password":"EdgePass3!","new_password":"NewEdge3b!"}' &
wait
预期: 一个 200、一个 400(current_password 已变导致验证失败)。极端并发下可能两个都 200(SQLite 串行写),但最终只有一个密码生效。
TC-EDGE-04: Cookie SameSite 验证
完整的 HTTP/HTTPS cookie 属性对比见 §3.3 TC-ATK-06/07/07a。
curl -s -D - -X POST $BASE/api/v1/auth/login/local \
-d "username=admin@example.com&password=正确密码" 2>/dev/null | grep -i set-cookie
预期: access_token → SameSite=lax,csrf_token → SameSite=strict
TC-EDGE-05: HTTP 无 max_age / HTTPS 有 max_age
# HTTP
curl -s -D - -X POST $BASE/api/v1/auth/login/local \
-d "username=admin@example.com&password=正确密码" 2>/dev/null \
| grep "access_token=" | grep -oi "max-age=[0-9]*" || echo "NO max-age (HTTP session cookie)"
# HTTPS
curl -s -D - -X POST $BASE/api/v1/auth/login/local \
-H "X-Forwarded-Proto: https" \
-d "username=admin@example.com&password=正确密码" 2>/dev/null \
| grep "access_token=" | grep -oi "max-age=[0-9]*"
预期: HTTP 无 Max-Age(session cookie,浏览器关闭即失效),HTTPS 有 Max-Age=604800(7 天)
TC-EDGE-06: public 路径 trailing slash
for path in /api/v1/auth/login/local/ /api/v1/auth/register/ \
/api/v1/auth/logout/ /api/v1/auth/setup-status/; do
echo "$path: $(curl -s -w '%{http_code}' -o /dev/null $BASE$path)"
done
预期: 全部 307(redirect 去掉 trailing slash)或 200/405,不是 401
7.5 红队对抗测试
模拟攻击者视角,验证防线没有可利用的缝隙。
7.5.1 路径混淆绕过
# 通过编码/双斜杠/路径穿越尝试绕过 AuthMiddleware 公开路径判断
for path in \
"//api/v1/auth/me" \
"/api/v1/auth/login/local/../me" \
"/api/v1/auth/login/local%2f..%2fme" \
"/api/v1/auth/login/local/..%2Fme" \
"/API/V1/AUTH/ME"; do
echo "$path: $(curl -s -w '%{http_code}' -o /dev/null $BASE$path)"
done
预期: 全部 401 或 404。不应有路径混淆导致跳过 auth 检查。
7.5.2 CSRF 对抗矩阵
# 登录拿 cookie
curl -s -X POST $BASE/api/v1/auth/login/local \
-d "username=admin@example.com&password=正确密码" -c cookies.txt
CSRF=$(grep csrf_token cookies.txt | awk '{print $NF}')
# Case 1: 有 cookie 无 header → 403
curl -s -w "%{http_code}" -o /dev/null \
-X POST $BASE/api/threads -b cookies.txt \
-H "Content-Type: application/json" -d '{"metadata":{}}'
# Case 2: 有 header 无 cookie → 403(删除 cookie 中的 csrf_token)
curl -s -w "%{http_code}" -o /dev/null \
-X POST $BASE/api/threads \
-b cookies.txt \
-H "X-CSRF-Token: $CSRF" \
-H "Content-Type: application/json" -d '{"metadata":{}}'
# Case 3: header 和 cookie 不匹配 → 403
curl -s -w "%{http_code}" -o /dev/null \
-X POST $BASE/api/threads -b cookies.txt \
-H "X-CSRF-Token: wrong-token" \
-H "Content-Type: application/json" -d '{"metadata":{}}'
# Case 4: 旧 CSRF token(登出再登录后) → 旧 token 应失效
curl -s -X POST $BASE/api/v1/auth/logout -b cookies.txt
curl -s -X POST $BASE/api/v1/auth/login/local \
-d "username=admin@example.com&password=正确密码" -c cookies.txt
# 用旧 CSRF 发请求
curl -s -w "%{http_code}" -o /dev/null \
-X POST $BASE/api/threads -b cookies.txt \
-H "X-CSRF-Token: $CSRF" \
-H "Content-Type: application/json" -d '{"metadata":{}}'
预期: Case 1-3 全部 403。Case 4 应 403(旧 CSRF 与新 cookie 不匹配)。
7.5.3 Token Replay(登出后旧 token 重放)
# 登录,保存 cookie
curl -s -X POST $BASE/api/v1/auth/login/local \
-d "username=admin@example.com&password=正确密码" -c cookies.txt
# 提取 access_token 值
TOKEN=$(grep access_token cookies.txt | awk '{print $NF}')
# 登出
curl -s -X POST $BASE/api/v1/auth/logout -b cookies.txt
# 手工注入旧 token(模拟攻击者窃取了 token)
curl -s -w "%{http_code}" -o /dev/null \
$BASE/api/v1/auth/me --cookie "access_token=$TOKEN"
预期: 200(已知限制:登出只清客户端 cookie,不 bump token_version。旧 token 在过期前仍有效)。
安全备注: 如需严格防重放,需在登出时 token_version += 1。当前设计选择不做,因为成本是所有设备的 session 全部失效。
7.5.4 跨站强制登出
# 攻击者从第三方站点 POST /logout(无需认证、无需 CSRF)
curl -s -X POST $BASE/api/v1/auth/logout -w "%{http_code}"
预期: 200(logout 是 public + CSRF 豁免)。
风险评估: 低——只影响可用性(被强制登出),不泄露数据。浏览器 SameSite=Lax 限制了真实跨站场景下 cookie 不会被带上,所以实际上第三方站点的 POST 不会清除用户 cookie。
7.5.5 Metadata 注入攻击(所有权伪造)
# 尝试在创建 thread 时注入其他用户的 user_id
CSRF=$(grep csrf_token cookies.txt | awk '{print $NF}')
curl -s -X POST $BASE/api/threads \
-b cookies.txt \
-H "Content-Type: application/json" \
-H "X-CSRF-Token: $CSRF" \
-d '{"metadata":{"owner_id":"victim-user-id"}}' | jq .metadata.owner_id
预期: 返回的 metadata.owner_id 应为当前登录用户的 ID,不是请求中注入的 victim-user-id。服务端应覆盖客户端提供的 user_id。
7.5.6 HTTP Method 探测
# HEAD/OPTIONS 不应泄露受保护资源信息
for method in HEAD OPTIONS TRACE; do
echo "$method /api/models: $(curl -s -w '%{http_code}' -o /dev/null -X $method $BASE/api/models)"
done
预期: HEAD/OPTIONS 返回 401 或 405。TRACE 应返回 405。
7.5.7 Rate Limiter IP 维度缺陷验证
# 通过不同的 X-Forwarded-For 绕过限速(验证是否用 client.host 而非 header)
for i in $(seq 1 6); do
curl -s -w "attempt $i: %{http_code}\n" -o /dev/null \
-X POST $BASE/api/v1/auth/login/local \
-H "X-Forwarded-For: 10.0.0.$i" \
-d "username=admin@example.com&password=wrong"
done
预期: 如果 rate limiter 基于 request.client.host(实际 TCP 连接 IP),所有请求来自同一 IP,第 6 个应返回 429。X-Forwarded-For 不应影响限速判断。
7.5.8 Junk Cookie 穿透验证
# middleware 只检查 cookie 存在性,不验证 JWT
# 确认 junk cookie 能过 middleware 但被下游 @require_auth 拦截
curl -s -w "%{http_code}" $BASE/api/v1/auth/me \
--cookie "access_token=not-a-jwt"
预期: 401(middleware 放行,get_current_user_from_request 解码失败返回 401)。
安全备注: middleware 是 presence-only 检查,有意设计。完整验证交给 @require_auth。
7.5.9 路由覆盖审计
# 列出所有注册的路由,检查哪些没有 @require_auth
cd backend && PYTHONPATH=. python3 -c "
from app.gateway.app import create_app
app = create_app()
public_prefixes = ['/health', '/docs', '/redoc', '/openapi.json',
'/api/v1/auth/login', '/api/v1/auth/register',
'/api/v1/auth/logout', '/api/v1/auth/setup-status']
for route in app.routes:
path = getattr(route, 'path', '')
if not path or not path.startswith('/api'):
continue
is_public = any(path.startswith(p) for p in public_prefixes)
if not is_public:
print(f' {path}')
" 2>/dev/null
预期: 列出的所有路由都应由 AuthMiddleware(cookie 存在性)+ @require_auth/@require_permission(JWT 验证)双层保护。检查是否有遗漏。
八、回归清单
每次 auth 相关代码变更后必须通过:
# 单元测试(168 个)
cd backend && PYTHONPATH=. uv run pytest \
tests/test_auth.py \
tests/test_auth_config.py \
tests/test_auth_errors.py \
tests/test_auth_type_system.py \
tests/test_auth_middleware.py \
tests/test_langgraph_auth.py \
-v
# 核心接口冒烟
curl -s $BASE/health # 200
curl -s $BASE/api/models # 401 (无 cookie)
curl -s -X POST $BASE/api/v1/auth/setup-status # 200
curl -s $BASE/api/v1/auth/me -b cookies.txt # 200 (有 cookie)