From f734e14d8b5004a6e5088499f27138f25e917653 Mon Sep 17 00:00:00 2001 From: greatmengqi Date: Tue, 12 May 2026 23:07:11 +0800 Subject: [PATCH] docs: document auth design and user isolation (#2913) * docs: document auth design and user isolation * docs: align auth docs with current storage and reset behavior --------- Co-authored-by: greatmengqi --- backend/app/gateway/app.py | 4 +- backend/app/gateway/auth/models.py | 2 +- backend/app/gateway/routers/auth.py | 2 +- backend/docs/API.md | 26 ++- backend/docs/AUTH_DESIGN.md | 331 +++++++++++++++++++++++++++ backend/docs/AUTH_TEST_DOCKER_GAP.md | 12 +- backend/docs/AUTH_TEST_PLAN.md | 254 +++++++++++--------- backend/docs/AUTH_UPGRADE.md | 59 +++-- backend/docs/README.md | 2 + 9 files changed, 547 insertions(+), 145 deletions(-) create mode 100644 backend/docs/AUTH_DESIGN.md diff --git a/backend/app/gateway/app.py b/backend/app/gateway/app.py index 8848f473e..2c13f571c 100644 --- a/backend/app/gateway/app.py +++ b/backend/app/gateway/app.py @@ -62,7 +62,7 @@ async def _ensure_admin_user(app: FastAPI) -> None: Subsequent boots (admin already exists): - Runs the one-time "no-auth → with-auth" orphan thread migration for - existing LangGraph thread metadata that has no owner_id. + existing LangGraph thread metadata that has no user_id. No SQL persistence migration is needed: the four user_id columns (threads_meta, runs, run_events, feedback) only come into existence @@ -177,7 +177,7 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: async with langgraph_runtime(app): logger.info("LangGraph runtime initialised") - # Ensure admin user exists (auto-create on first boot) + # Check admin bootstrap state and migrate orphan threads after admin exists. # Must run AFTER langgraph_runtime so app.state.store is available for thread migration await _ensure_admin_user(app) diff --git a/backend/app/gateway/auth/models.py b/backend/app/gateway/auth/models.py index d8f9b954a..25c6476fe 100644 --- a/backend/app/gateway/auth/models.py +++ b/backend/app/gateway/auth/models.py @@ -28,7 +28,7 @@ class User(BaseModel): oauth_id: str | None = Field(None, description="User ID from OAuth provider") # Auth lifecycle - needs_setup: bool = Field(default=False, description="True for auto-created admin until setup completes") + needs_setup: bool = Field(default=False, description="True when a reset account must complete setup") token_version: int = Field(default=0, description="Incremented on password change to invalidate old JWTs") diff --git a/backend/app/gateway/routers/auth.py b/backend/app/gateway/routers/auth.py index 3a41e13eb..6192456fb 100644 --- a/backend/app/gateway/routers/auth.py +++ b/backend/app/gateway/routers/auth.py @@ -305,7 +305,7 @@ async def login_local( async def register(request: Request, response: Response, body: RegisterRequest): """Register a new user account (always 'user' role). - Admin is auto-created on first boot. This endpoint creates regular users. + The first admin is created explicitly through /initialize. This endpoint creates regular users. Auto-login by setting the session cookie. """ try: diff --git a/backend/docs/API.md b/backend/docs/API.md index d0b06ef0b..762a135c4 100644 --- a/backend/docs/API.md +++ b/backend/docs/API.md @@ -535,14 +535,28 @@ All APIs return errors in a consistent format: ## Authentication -Currently, DeerFlow does not implement authentication. All APIs are accessible without credentials. +DeerFlow enforces authentication for all non-public HTTP routes. Public routes are limited to health/docs metadata and these public auth endpoints: -Note: This is about DeerFlow API authentication. MCP outbound connections can still use OAuth for configured HTTP/SSE MCP servers. +- `POST /api/v1/auth/initialize` creates the first admin account when no admin exists. +- `POST /api/v1/auth/login/local` logs in with email/password and sets an HttpOnly `access_token` cookie. +- `POST /api/v1/auth/register` creates a regular `user` account and sets the session cookie. +- `POST /api/v1/auth/logout` clears the session cookie. +- `GET /api/v1/auth/setup-status` reports whether the first admin still needs to be created. -For production deployments, it is recommended to: -1. Use Nginx for basic auth or OAuth integration -2. Deploy behind a VPN or private network -3. Implement custom authentication middleware +The authenticated auth endpoints are: + +- `GET /api/v1/auth/me` returns the current user. +- `POST /api/v1/auth/change-password` changes password, optionally changes email during setup, increments `token_version`, and reissues the cookie. + +Protected state-changing requests also require the CSRF double-submit token: send the `csrf_token` cookie value as the `X-CSRF-Token` header. Login/register/initialize/logout are bootstrap auth endpoints: they are exempt from the double-submit token but still reject hostile browser `Origin` headers. + +User isolation is enforced from the authenticated user context: + +- Thread metadata is scoped by `threads_meta.user_id`; search/read/write/delete APIs only expose the current user's threads. +- Thread files live under `{base_dir}/users/{user_id}/threads/{thread_id}/user-data/` and are exposed inside the sandbox as `/mnt/user-data/`. +- Memory and custom agents are stored under `{base_dir}/users/{user_id}/...`. + +Note: MCP outbound connections can still use OAuth for configured HTTP/SSE MCP servers; that is separate from DeerFlow API authentication. --- diff --git a/backend/docs/AUTH_DESIGN.md b/backend/docs/AUTH_DESIGN.md new file mode 100644 index 000000000..9a740871d --- /dev/null +++ b/backend/docs/AUTH_DESIGN.md @@ -0,0 +1,331 @@ +# 用户认证与隔离设计 + +本文档描述 DeerFlow 当前内置认证模块的设计,而不是历史 RFC。它覆盖浏览器登录、API 认证、CSRF、用户隔离、首次初始化、密码重置、内部调用和升级迁移。 + +## 设计目标 + +认证模块的核心目标是把 DeerFlow 从“本地单用户工具”提升为“可多用户部署的 agent runtime”,并让用户身份贯穿 HTTP API、LangGraph-compatible runtime、文件系统、memory、自定义 agent 和反馈数据。 + +设计约束: + +- 默认强制认证:除健康检查、文档和 auth bootstrap 端点外,HTTP 路由都必须有有效 session。 +- 服务端持有所有权:客户端 metadata 不能声明 `user_id` 或 `owner_id`。 +- 隔离默认开启:repository(仓储)、文件路径、memory、agent 配置默认按当前用户解析。 +- 旧数据可升级:无认证版本留下的 thread 可以在 admin 存在后迁移到 admin。 +- 密码不进日志:首次初始化由操作者设置密码;`reset_admin` 只写 0600 凭据文件。 + +非目标: + +- 当前 OAuth 端点只是占位,尚未实现第三方登录。 +- 当前用户角色只有 `admin` 和 `user`,尚未实现细粒度 RBAC。 +- 当前登录限速是进程内字典,多 worker 下不是全局精确限速。 + +## 核心模型 + +```mermaid +graph TB + classDef actor fill:#D8CFC4,stroke:#6E6259,color:#2F2A26; + classDef api fill:#C9D7D2,stroke:#5D706A,color:#21302C; + classDef state fill:#D7D3E8,stroke:#6B6680,color:#29263A; + classDef data fill:#E5D2C4,stroke:#806A5B,color:#30251E; + + Browser["Browser — access_token cookie and csrf_token cookie"]:::actor + AuthMiddleware["AuthMiddleware — strict session gate"]:::api + CSRFMiddleware["CSRFMiddleware — double-submit token and Origin check"]:::api + AuthRoutes["Auth routes — initialize login register logout me change-password"]:::api + UserContext["Current user ContextVar — request-scoped identity"]:::state + Repositories["Repositories — AUTO resolves user_id from context"]:::state + Files["Filesystem — users/{user_id}/threads/{thread_id}/user-data"]:::data + Memory["Memory and agents — users/{user_id}/memory.json and agents"]:::data + + Browser --> AuthMiddleware + Browser --> CSRFMiddleware + AuthMiddleware --> AuthRoutes + AuthMiddleware --> UserContext + UserContext --> Repositories + UserContext --> Files + UserContext --> Memory +``` + +### 用户表 + +用户记录定义在 `app.gateway.auth.models.User`,持久化到 `users` 表。关键字段: + +| 字段 | 语义 | +|---|---| +| `id` | 用户主键,JWT `sub` 使用该值 | +| `email` | 唯一登录名 | +| `password_hash` | bcrypt hash,OAuth 用户可为空 | +| `system_role` | `admin` 或 `user` | +| `needs_setup` | reset 后要求用户完成邮箱 / 密码设置 | +| `token_version` | 改密码或 reset 时递增,用于废弃旧 JWT | + +### 运行时身份 + +认证成功后,`AuthMiddleware` 把用户同时写入: + +- `request.state.user` +- `request.state.auth` +- `deerflow.runtime.user_context` 的 `ContextVar` + +`ContextVar` 是这里的核心边界。上层 Gateway 负责写入身份,下层 persistence / file path 只读取结构化的当前用户,不反向依赖 `app.gateway.auth` 具体类型。 + +可以把 repository 调用的用户参数理解成一个三态 ADT: + +```scala +enum UserScope: + case AutoFromContext + case Explicit(userId: String) + case BypassForMigration +``` + +对应 Python 实现是 `AUTO | str | None`: + +- `AUTO`:从 `ContextVar` 解析当前用户;没有上下文则抛错。 +- `str`:显式指定用户,主要用于测试或管理脚本。 +- `None`:跳过用户过滤,只允许迁移脚本或 admin CLI 使用。 + +## 登录与初始化流程 + +### 首次初始化 + +首次启动时,如果没有 admin,服务不会自动创建账号,只记录日志提示访问 `/setup`。 + +流程: + +1. 用户访问 `/setup`。 +2. 前端调用 `GET /api/v1/auth/setup-status`。 +3. 如果返回 `{"needs_setup": true}`,前端展示创建 admin 表单。 +4. 表单提交 `POST /api/v1/auth/initialize`。 +5. 服务端确认当前没有 admin,创建 `system_role="admin"`、`needs_setup=false` 的用户。 +6. 服务端设置 `access_token` HttpOnly cookie,用户进入 workspace。 + +`/api/v1/auth/initialize` 只在没有 admin 时可用。并发初始化由数据库唯一约束兜底,失败方返回 409。 + +### 普通登录 + +`POST /api/v1/auth/login/local` 使用 `OAuth2PasswordRequestForm`: + +- `username` 是邮箱。 +- `password` 是密码。 +- 成功后签发 JWT,放入 `access_token` HttpOnly cookie。 +- 响应体只返回 `expires_in` 和 `needs_setup`,不返回 token。 + +登录失败会按客户端 IP 计数。IP 解析只在 TCP peer 属于 `AUTH_TRUSTED_PROXIES` 时信任 `X-Real-IP`,不使用 `X-Forwarded-For`。 + +### 注册 + +`POST /api/v1/auth/register` 创建普通 `user`,并自动登录。 + +当前实现允许在没有 admin 时注册普通用户,但 `setup-status` 仍会返回 `needs_setup=true`,因为 admin 仍不存在。这是当前产品策略边界:如果后续要求“必须先初始化 admin 才能注册普通用户”,需要在 `/register` 增加 admin-exists gate。 + +### 改密码与 reset setup + +`POST /api/v1/auth/change-password` 需要当前密码和新密码: + +- 校验当前密码。 +- 更新 bcrypt hash。 +- `token_version += 1`,使旧 JWT 立即失效。 +- 重新签发 cookie。 +- 如果 `needs_setup=true` 且传了 `new_email`,则更新邮箱并清除 `needs_setup`。 + +`python -m app.gateway.auth.reset_admin` 会: + +- 找到 admin 或指定邮箱用户。 +- 生成随机密码。 +- 更新密码 hash。 +- `token_version += 1`。 +- 设置 `needs_setup=true`。 +- 写入 `.deer-flow/admin_initial_credentials.txt`,权限 `0600`。 + +命令行只输出凭据文件路径,不输出明文密码。 + +## HTTP 认证边界 + +`AuthMiddleware` 是 fail-closed(默认拒绝)的全局认证门。 + +公开路径: + +- `/health` +- `/docs` +- `/redoc` +- `/openapi.json` +- `/api/v1/auth/login/local` +- `/api/v1/auth/register` +- `/api/v1/auth/logout` +- `/api/v1/auth/setup-status` +- `/api/v1/auth/initialize` + +其余路径都要求有效 `access_token` cookie。存在 cookie 但 JWT 无效、过期、用户不存在或 `token_version` 不匹配时,直接返回 401,而不是让请求穿透到业务路由。 + +路由级别的 owner check 由 `require_permission(..., owner_check=True)` 完成: + +- 读类请求允许旧的未追踪 legacy thread 兼容读取。 +- 写 / 删除类请求使用 `require_existing=True`,要求 thread row 存在且属于当前用户,避免删除后缺 row 导致其他用户误通过。 + +## CSRF 设计 + +DeerFlow 使用 Double Submit Cookie: + +- 服务端设置 `csrf_token` cookie。 +- 前端 state-changing 请求发送同值 `X-CSRF-Token` header。 +- 服务端用 `secrets.compare_digest` 比较 cookie/header。 + +需要 CSRF 的方法: + +- `POST` +- `PUT` +- `DELETE` +- `PATCH` + +auth bootstrap 端点(login/register/initialize/logout)不要求 double-submit token,因为首次调用时浏览器还没有 token;但这些端点会校验 browser `Origin`,拒绝 hostile Origin,避免 login CSRF / session fixation。 + +## 用户隔离 + +### Thread metadata + +Thread metadata 存在 `threads_meta`,关键隔离字段是 `user_id`。 + +创建 thread 时: + +- 客户端传入的 `metadata.user_id` 和 `metadata.owner_id` 会被剥离。 +- `ThreadMetaRepository.create(..., user_id=AUTO)` 从 `ContextVar` 解析真实用户。 +- `/api/threads/search` 默认只返回当前用户的 thread。 + +读取 / 修改 / 删除时: + +- `get()` 默认按当前用户过滤。 +- `check_access()` 用于路由 owner check。 +- 对其他用户的 thread 返回 404,避免泄露资源存在性。 + +### 文件系统 + +当前线程文件布局: + +```text +{base_dir}/users/{user_id}/threads/{thread_id}/user-data/ +├── workspace/ +├── uploads/ +└── outputs/ +``` + +agent 在 sandbox 内看到统一虚拟路径: + +```text +/mnt/user-data/workspace +/mnt/user-data/uploads +/mnt/user-data/outputs +``` + +`ThreadDataMiddleware` 使用 `get_effective_user_id()` 解析当前用户并生成线程路径。没有认证上下文时会落到 `default` 用户桶,主要用于内部调用、嵌入式 client 或无 HTTP 的本地执行路径。 + +### Memory + +默认 memory 存储: + +```text +{base_dir}/users/{user_id}/memory.json +{base_dir}/users/{user_id}/agents/{agent_name}/memory.json +``` + +有用户上下文时,空或相对 `memory.storage_path` 都使用上述 per-user 默认路径;只有绝对 `memory.storage_path` 会视为显式 opt-out(退出) per-user isolation,所有用户共享该路径。无用户上下文的 legacy 路径仍会把相对 `storage_path` 解析到 `Paths.base_dir` 下。 + +### 自定义 agent + +用户自定义 agent 写入: + +```text +{base_dir}/users/{user_id}/agents/{agent_name}/ +├── config.yaml +├── SOUL.md +└── memory.json +``` + +旧布局 `{base_dir}/agents/{agent_name}/` 只作为只读兼容回退。更新或删除旧共享 agent 会要求先运行迁移脚本。 + +## 内部调用与 IM 渠道 + +IM channel worker 不是浏览器用户,不持有浏览器 cookie。它们通过 Gateway 内部认证: + +- 请求带 `X-DeerFlow-Internal-Token`。 +- 同时带匹配的 CSRF cookie/header。 +- 服务端识别为内部用户,`id="default"`、`system_role="internal"`。 + +这意味着 channel 产生的数据默认进入 `default` 用户桶。这个选择适合“平台级 bot 身份”,但不是“每个 IM 用户单独隔离”。如果后续要做到外部 IM 用户隔离,需要把外部 platform user 映射到 DeerFlow user,并让 channel manager 设置对应的 scoped identity。 + +## LangGraph-compatible 认证 + +Gateway 内嵌 runtime 路径由 `AuthMiddleware` 和 `CSRFMiddleware` 保护。 + +仓库仍保留 `app.gateway.langgraph_auth`,用于 LangGraph Server 直连模式: + +- `@auth.authenticate` 校验 JWT cookie、CSRF、用户存在性和 `token_version`。 +- `@auth.on` 在写入 metadata 时注入 `user_id`,并在读路径返回 `{"user_id": current_user}` 过滤条件。 + +这保证 Gateway 路由和 LangGraph-compatible 直连模式使用同一 JWT 语义。 + +## 升级与迁移 + +从无认证版本升级时,可能存在没有 `user_id` 的历史 thread。 + +当前策略: + +1. 首次启动如果没有 admin,只提示访问 `/setup`,不迁移。 +2. 操作者创建 admin。 +3. 后续启动时,`_ensure_admin_user()` 找到 admin,并把 LangGraph store 中缺少 `metadata.user_id` 的 thread 迁移到 admin。 + +文件系统旧布局迁移由脚本处理: + +```bash +cd backend +PYTHONPATH=. python scripts/migrate_user_isolation.py --dry-run +PYTHONPATH=. python scripts/migrate_user_isolation.py --user-id +``` + +迁移脚本覆盖 legacy `memory.json`、`threads/` 和 `agents/` 到 per-user layout。 + +## 安全不变量 + +必须长期保持的不变量: + +- JWT 只在 HttpOnly cookie 中传输,不出现在响应 JSON。 +- 任何非 public HTTP 路由都不能只靠“cookie 存在”放行,必须严格验证 JWT。 +- `token_version` 不匹配必须拒绝,保证改密码 / reset 后旧 session 失效。 +- 客户端 metadata 中的 `user_id` / `owner_id` 必须剥离。 +- repository 默认 `AUTO` 必须从当前用户上下文解析,不能静默退化成全局查询。 +- 只有迁移脚本和 admin CLI 可以显式传 `user_id=None` 绕过隔离。 +- 本地文件路径必须通过 `Paths` 和 sandbox path validation 解析,不能拼接未校验的用户输入。 +- 捕获认证、迁移、后台任务异常必须记录日志;不能空 catch。 + +## 已知边界 + +| 边界 | 当前行为 | 后续方向 | +|---|---|---| +| 无 admin 时注册普通用户 | 允许注册普通 `user` | 如产品要求先初始化 admin,给 `/register` 加 gate | +| 登录限速 | 进程内 dict,单 worker 精确,多 worker 近似 | Redis / DB-backed rate limiter | +| OAuth | 端点占位,未实现 | 接入 provider 并统一 `token_version` / role 语义 | +| IM 用户隔离 | channel 使用 `default` 内部用户 | 建立外部用户到 DeerFlow user 的映射 | +| 绝对 memory path | 显式共享 memory | UI / docs 明确提示 opt-out 风险 | + +## 相关文件 + +| 文件 | 职责 | +|---|---| +| `app/gateway/auth_middleware.py` | 全局认证门、JWT 严格验证、写入 user context | +| `app/gateway/csrf_middleware.py` | CSRF double-submit 和 auth Origin 校验 | +| `app/gateway/routers/auth.py` | initialize/login/register/logout/me/change-password | +| `app/gateway/auth/jwt.py` | JWT 创建与解析 | +| `app/gateway/auth/reset_admin.py` | 密码 reset CLI | +| `app/gateway/auth/credential_file.py` | 0600 凭据文件写入 | +| `app/gateway/authz.py` | 路由权限与 owner check | +| `deerflow/runtime/user_context.py` | 当前用户 ContextVar 与 `AUTO` sentinel | +| `deerflow/persistence/thread_meta/` | thread metadata owner filter | +| `deerflow/config/paths.py` | per-user filesystem layout | +| `deerflow/agents/middlewares/thread_data_middleware.py` | run 时解析用户线程目录 | +| `deerflow/agents/memory/storage.py` | per-user memory storage | +| `deerflow/config/agents_config.py` | per-user custom agents | +| `app/channels/manager.py` | IM channel 内部认证调用 | +| `scripts/migrate_user_isolation.py` | legacy 数据迁移到 per-user layout | +| `.deer-flow/data/deerflow.db` | 统一 SQLite 数据库,包含 users / threads_meta / runs / feedback 等表 | +| `.deer-flow/users/{user_id}/agents/{agent_name}/` | 用户自定义 agent 配置、SOUL 和 agent memory | +| `.deer-flow/admin_initial_credentials.txt` | `reset_admin` 生成的新凭据文件(0600,读完应删除) | diff --git a/backend/docs/AUTH_TEST_DOCKER_GAP.md b/backend/docs/AUTH_TEST_DOCKER_GAP.md index adf4916a3..969aad92c 100644 --- a/backend/docs/AUTH_TEST_DOCKER_GAP.md +++ b/backend/docs/AUTH_TEST_DOCKER_GAP.md @@ -24,11 +24,11 @@ All other test plan sections were executed against either: | Case | Title | What it covers | Why not run | |---|---|---|---| -| TC-DOCKER-01 | `users.db` volume persistence | Verify the `DEER_FLOW_HOME` bind mount survives container restart | needs `docker compose up` | +| TC-DOCKER-01 | `deerflow.db` volume persistence | Verify the `DEER_FLOW_HOME` bind mount survives container restart | needs `docker compose up` | | TC-DOCKER-02 | Session persistence across container restart | `AUTH_JWT_SECRET` env var keeps cookies valid after `docker compose down && up` | needs `docker compose down/up` | | TC-DOCKER-03 | Per-worker rate limiter divergence | Confirms in-process `_login_attempts` dict doesn't share state across `gunicorn` workers (4 by default in the compose file); known limitation, documented | needs multi-worker container | -| TC-DOCKER-04 | IM channels skip AuthMiddleware | Verify Feishu/Slack/Telegram dispatchers run in-container against `http://langgraph:2024` without going through nginx | needs `docker logs` | -| TC-DOCKER-05 | Admin credentials surfacing | **Updated post-simplify** — was "log scrape", now "0600 credential file in `DEER_FLOW_HOME`". The file-based behavior is already validated by TC-1.1 + TC-UPG-13 on sg_dev (non-Docker), so the only Docker-specific gap is verifying the volume mount carries the file out to the host | needs container + host volume | +| TC-DOCKER-04 | IM channels use internal Gateway auth | Verify Feishu/Slack/Telegram dispatchers attach the process-local internal auth header plus CSRF cookie/header when calling Gateway-compatible LangGraph APIs | needs `docker logs` | +| TC-DOCKER-05 | Reset credentials surfacing | `reset_admin` writes a 0600 credential file in `DEER_FLOW_HOME` instead of logging plaintext. The file-based behavior is validated by non-Docker reset tests, so the only Docker-specific gap is verifying the volume mount carries the file out to the host | needs container + host volume | | TC-DOCKER-06 | Gateway-mode Docker deploy | `./scripts/deploy.sh --gateway` produces a 3-container topology (no `langgraph` container); same auth flow as standard mode | needs `docker compose --profile gateway` | ## Coverage already provided by non-Docker tests @@ -41,8 +41,8 @@ the test cases that ran on sg_dev or local: | TC-DOCKER-01 (volume persistence) | TC-REENT-01 on sg_dev (admin row survives gateway restart) — same SQLite file, just no container layer between | | TC-DOCKER-02 (session persistence) | TC-API-02/03/06 (cookie roundtrip), plus TC-REENT-04 (multi-cookie) — JWT verification is process-state-free, container restart is equivalent to `pkill uvicorn && uv run uvicorn` | | TC-DOCKER-03 (per-worker rate limit) | TC-GW-04 + TC-REENT-09 (single-worker rate limit + 5min expiry). The cross-worker divergence is an architectural property of the in-memory dict; no auth code path differs | -| TC-DOCKER-04 (IM channels skip auth) | Code-level only: `app/channels/manager.py` uses `langgraph_sdk` directly with no cookie handling. The langgraph_auth handler is bypassed by going through SDK, not HTTP | -| TC-DOCKER-05 (credential surfacing) | TC-1.1 on sg_dev (file at `~/deer-flow/backend/.deer-flow/admin_initial_credentials.txt`, mode 0600, password 22 chars) — the only Docker-unique step is whether the bind mount projects this path onto the host, which is a `docker compose` config check, not a runtime behavior change | +| TC-DOCKER-04 (IM channels use internal auth) | Code-level: `app/channels/manager.py` creates the `langgraph_sdk` client with `create_internal_auth_headers()` plus CSRF cookie/header, so channel workers do not rely on browser cookies | +| TC-DOCKER-05 (credential surfacing) | `reset_admin` writes `.deer-flow/admin_initial_credentials.txt` with mode 0600 and logs only the path — the only Docker-unique step is whether the bind mount projects this path onto the host, which is a `docker compose` config check, not a runtime behavior change | | TC-DOCKER-06 (gateway-mode container) | Section 七 7.2 covered by TC-GW-01..05 + Section 二 (gateway-mode auth flow on sg_dev) — same Gateway code, container is just a packaging change | ## Reproduction steps when Docker becomes available @@ -72,6 +72,6 @@ Then run TC-DOCKER-01..06 from the test plan as written. about *container packaging* details (bind mounts, multi-worker, log collection), not about whether the auth code paths work. - **TC-DOCKER-05 was updated in place** in `AUTH_TEST_PLAN.md` to reflect - the post-simplify reality (credentials file → 0600 file, no log leak). + the current reset flow (`reset_admin` → 0600 credentials file, no log leak). The old "grep 'Password:' in docker logs" expectation would have failed silently and given a false sense of coverage. diff --git a/backend/docs/AUTH_TEST_PLAN.md b/backend/docs/AUTH_TEST_PLAN.md index 15b20494a..e5245d60b 100644 --- a/backend/docs/AUTH_TEST_PLAN.md +++ b/backend/docs/AUTH_TEST_PLAN.md @@ -19,7 +19,7 @@ ```bash # 清除已有数据 -rm -f backend/.deer-flow/users.db +rm -f backend/.deer-flow/data/deerflow.db # 选择模式启动 make dev # 标准模式 @@ -28,10 +28,11 @@ make dev-pro # Gateway 模式 ``` **验证点:** -- [ ] 控制台输出 admin 邮箱和随机密码 -- [ ] 密码格式为 `secrets.token_urlsafe(16)` 的 22 字符字符串 -- [ ] 邮箱为 `admin@deerflow.dev` -- [ ] 提示 `Change it after login: Settings -> Account` +- [ ] 控制台不输出 admin 邮箱或明文密码 +- [ ] 控制台提示 `First boot detected — no admin account exists.` +- [ ] 控制台提示访问 `/setup` 完成 admin 创建 +- [ ] `GET /api/v1/auth/setup-status` 返回 `{"needs_setup": true}` +- [ ] 前端访问 `/login` 会跳转 `/setup` ### 1.2 非首次启动 @@ -42,7 +43,8 @@ make dev **验证点:** - [ ] 控制台不输出密码 -- [ ] 如果 admin 仍 `needs_setup=True`,控制台有 warning 提示 +- [ ] `GET /api/v1/auth/setup-status` 返回 `{"needs_setup": false}` +- [ ] 已登录用户如果 `needs_setup=True`,访问 workspace 会被引导到 `/setup` 完成改邮箱 / 改密码流程 ### 1.3 环境变量配置 @@ -76,19 +78,22 @@ make dev curl -s $BASE/api/v1/auth/setup-status | jq . ``` -**预期:** 返回 `{"needs_setup": false}`(admin 在启动时已自动创建,`count_users() > 0`)。仅在启动完成前的极短窗口内可能返回 `true`。 +**预期:** +- 干净数据库且尚未初始化 admin:返回 `{"needs_setup": true}` +- 已存在 admin:返回 `{"needs_setup": false}` -#### TC-API-02: Admin 首次登录 +#### TC-API-02: 首次初始化 Admin ```bash -curl -s -X POST $BASE/api/v1/auth/login/local \ - -d "username=admin@deerflow.dev&password=<控制台密码>" \ +curl -s -X POST $BASE/api/v1/auth/initialize \ + -H "Content-Type: application/json" \ + -d '{"email":"admin@example.com","password":"AdminPass1!"}' \ -c cookies.txt | jq . ``` **预期:** -- 状态码 200 -- Body: `{"expires_in": 604800, "needs_setup": true}` +- 状态码 201 +- Body: `{"id": "...", "email": "admin@example.com", "system_role": "admin", "needs_setup": false}` - `cookies.txt` 包含 `access_token`(HttpOnly)和 `csrf_token`(非 HttpOnly) #### TC-API-03: 获取当前用户 @@ -97,9 +102,9 @@ curl -s -X POST $BASE/api/v1/auth/login/local \ curl -s $BASE/api/v1/auth/me -b cookies.txt | jq . ``` -**预期:** `{"id": "...", "email": "admin@deerflow.dev", "system_role": "admin", "needs_setup": true}` +**预期:** `{"id": "...", "email": "admin@example.com", "system_role": "admin", "needs_setup": false}` -#### TC-API-04: Setup 流程(改邮箱 + 改密码) +#### TC-API-04: 改密码流程 ```bash CSRF=$(grep csrf_token cookies.txt | awk '{print $NF}') @@ -107,13 +112,36 @@ 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 . + -d '{"current_password":"AdminPass1!","new_password":"NewPass123!"}' | jq . ``` **预期:** - 状态码 200 - `{"message": "Password changed successfully"}` -- 再调 `/auth/me` 邮箱变为 `admin@example.com`,`needs_setup` 变为 `false` +- 再调 `/auth/me` 仍为 `admin@example.com`,`needs_setup` 仍为 `false` + +#### TC-API-04a: reset_admin 后的 Setup 流程(改邮箱 + 改密码) + +```bash +cd backend +python -m app.gateway.auth.reset_admin --email admin@example.com +# 从 .deer-flow/admin_initial_credentials.txt 读取 reset 后密码 + +curl -s -X POST $BASE/api/v1/auth/login/local \ + -d "username=admin@example.com&password=<凭据文件密码>" \ + -c cookies.txt | jq . + +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":"AdminPass2!","new_email":"admin2@example.com"}' | jq . +``` + +**预期:** +- 登录返回 `{"expires_in": 604800, "needs_setup": true}` +- `change-password` 后 `/auth/me` 邮箱变为 `admin2@example.com`,`needs_setup` 变为 `false` #### TC-API-05: 普通用户注册 @@ -493,7 +521,7 @@ curl -s -X POST $BASE/api/v1/auth/register \ ```bash # 检查数据库 -sqlite3 backend/.deer-flow/users.db "SELECT email, password_hash FROM users LIMIT 3;" +sqlite3 backend/.deer-flow/data/deerflow.db "SELECT email, password_hash FROM users LIMIT 3;" ``` **预期:** `password_hash` 以 `$2b$` 开头(bcrypt 格式) @@ -506,24 +534,25 @@ sqlite3 backend/.deer-flow/users.db "SELECT email, password_hash FROM users LIMI ### 4.1 首次登录流程 -#### TC-UI-01: 访问首页跳转登录 +#### TC-UI-01: 无 admin 时访问 workspace 跳转 setup 1. 打开 `http://localhost:2026/workspace` -2. **预期:** 自动跳转到 `/login` +2. **预期:** 自动跳转到 `/setup` -#### TC-UI-02: Login 页面 +#### TC-UI-02: Setup 页面创建 admin -1. 输入 admin 邮箱和控制台密码 -2. 点击 Login -3. **预期:** 跳转到 `/setup`(因为 `needs_setup=true`) - -#### TC-UI-03: Setup 页面 - -1. 输入新邮箱、控制台密码(current)、新密码、确认密码 -2. 点击 Complete Setup +1. 输入 admin 邮箱、密码、确认密码 +2. 点击 Create Admin Account 3. **预期:** 跳转到 `/workspace` 4. 刷新页面不跳回 `/setup` +#### TC-UI-03: 已初始化后 Login 页面 + +1. 退出登录后访问 `/login` +2. 输入 admin 邮箱和密码 +3. 点击 Login +4. **预期:** 跳转到 `/workspace` + #### TC-UI-04: Setup 密码不匹配 1. 新密码和确认密码不一致 @@ -602,7 +631,7 @@ sqlite3 backend/.deer-flow/users.db "SELECT email, password_hash FROM users LIMI #### TC-UI-15: reset_admin 后重新登录 1. 执行 `cd backend && python -m app.gateway.auth.reset_admin` -2. 使用新密码登录 +2. 从 `.deer-flow/admin_initial_credentials.txt` 读取新密码并登录 3. **预期:** 跳转到 `/setup` 页面(`needs_setup` 被重置为 true) 4. 旧 session 已失效 @@ -645,18 +674,28 @@ make install make dev ``` -#### TC-UPG-01: 首次启动创建 admin +#### TC-UPG-01: 首次启动等待 admin 初始化 **预期:** -- [ ] 控制台输出 admin 邮箱(`admin@deerflow.dev`)和随机密码 +- [ ] 控制台不输出 admin 邮箱或随机密码 +- [ ] 访问 `/setup` 可创建第一个 admin - [ ] 无报错,正常启动 #### TC-UPG-02: 旧 Thread 迁移到 admin ```bash +# 创建第一个 admin +curl -s -X POST http://localhost:2026/api/v1/auth/initialize \ + -H "Content-Type: application/json" \ + -d '{"email":"admin@example.com","password":"AdminPass1!"}' \ + -c cookies.txt + +# 重启一次:启动迁移只在已有 admin 的启动路径执行 +make stop && make dev + # 登录 admin curl -s -X POST http://localhost:2026/api/v1/auth/login/local \ - -d "username=admin@deerflow.dev&password=<控制台密码>" \ + -d "username=admin@example.com&password=AdminPass1!" \ -c cookies.txt # 查看 thread 列表 @@ -670,8 +709,8 @@ curl -s -X POST http://localhost:2026/api/threads/search \ **预期:** - [ ] 返回的 thread 数量 ≥ 旧版创建的数量 -- [ ] 控制台日志有 `Migrated N orphaned thread(s) to admin` -- [ ] 每个 thread 的 `metadata.owner_id` 都已被设为 admin 的 ID +- [ ] 控制台日志有 `Migrated N orphan LangGraph thread(s) to admin` +- [ ] 旧 thread 只对 admin 可见 #### TC-UPG-03: 旧 Thread 内容完整 @@ -683,7 +722,7 @@ curl -s http://localhost:2026/api/threads/ \ **预期:** - [ ] `metadata.title` 保留原值(如 `old-thread-1`) -- [ ] `metadata.owner_id` 已填充 +- [ ] 响应不回显服务端保留的 `user_id` / `owner_id` #### TC-UPG-04: 新用户看不到旧 Thread @@ -706,18 +745,19 @@ curl -s -X POST http://localhost:2026/api/threads/search \ ### 5.3 数据库 Schema 兼容 -#### TC-UPG-05: 无 users.db 时自动创建 +#### TC-UPG-05: 无 deerflow.db 时创建 schema 但不创建默认用户 ```bash -ls -la backend/.deer-flow/users.db +ls -la backend/.deer-flow/data/deerflow.db +sqlite3 backend/.deer-flow/data/deerflow.db "SELECT COUNT(*) FROM users;" ``` -**预期:** 文件存在,`sqlite3` 可查到 `users` 表含 `needs_setup`、`token_version` 列 +**预期:** 文件存在,`sqlite3` 可查到 `users` 表含 `needs_setup`、`token_version` 列;未调用 `/initialize` 前用户数为 0 -#### TC-UPG-06: users.db WAL 模式 +#### TC-UPG-06: deerflow.db WAL 模式 ```bash -sqlite3 backend/.deer-flow/users.db "PRAGMA journal_mode;" +sqlite3 backend/.deer-flow/data/deerflow.db "PRAGMA journal_mode;" ``` **预期:** 返回 `wal` @@ -768,9 +808,9 @@ make dev ``` **预期:** -- [ ] 服务正常启动(忽略 `users.db`,无 auth 相关代码不报错) +- [ ] 服务正常启动(忽略 `deerflow.db`,无 auth 相关代码不报错) - [ ] 旧对话数据仍然可访问 -- [ ] `users.db` 文件残留但不影响运行 +- [ ] `deerflow.db` 文件残留但不影响运行 #### TC-UPG-12: 再次升级到 auth 分支 @@ -781,51 +821,47 @@ make dev ``` **预期:** -- [ ] 识别已有 `users.db`,不重新创建 admin -- [ ] 旧的 admin 账号仍可登录(如果回退期间未删 `users.db`) +- [ ] 识别已有 `deerflow.db`,不重新创建 admin +- [ ] 旧的 admin 账号仍可登录(如果回退期间未删 `deerflow.db`) -### 5.7 休眠 Admin(初始密码未使用/未更改) +### 5.7 Admin 初始化与 reset_admin -> 首次启动生成 admin + 随机密码,但运维未登录、未改密码。 -> 密码只在首次启动的控制台闪过一次,后续启动不再显示。 +> 首次启动不生成默认 admin,也不在日志输出密码。忘记密码时走 `reset_admin`,新密码写入 0600 凭据文件。 -#### TC-UPG-13: 重启后自动重置密码并打印 +#### TC-UPG-13: 未初始化 admin 时重启不创建默认账号 ```bash -# 首次启动,记录密码 -rm -f backend/.deer-flow/users.db +rm -f backend/.deer-flow/data/deerflow.db make dev -# 控制台输出密码 P0,不登录 make stop -# 隔了几天,再次启动 make dev -# 控制台输出新密码 P1 +curl -s $BASE/api/v1/auth/setup-status | jq . ``` **预期:** -- [ ] 控制台输出 `Admin account setup incomplete — password reset` -- [ ] 输出新密码 P1(P0 已失效) -- [ ] 用 P1 可以登录,P0 不可以 -- [ ] 登录后 `needs_setup=true`,跳转 `/setup` -- [ ] `token_version` 递增(旧 session 如有也失效) +- [ ] 控制台不输出密码 +- [ ] `setup-status` 仍为 `{"needs_setup": true}` +- [ ] 访问 `/setup` 仍可创建第一个 admin -#### TC-UPG-14: 密码丢失 — 无需 CLI,重启即可 +#### TC-UPG-14: 密码丢失 — reset_admin 写入凭据文件 ```bash -# 忘记了控制台密码 → 直接重启服务 -make stop && make dev -# 控制台自动输出新密码 +python -m app.gateway.auth.reset_admin --email admin@example.com +ls -la backend/.deer-flow/admin_initial_credentials.txt +cat backend/.deer-flow/admin_initial_credentials.txt ``` **预期:** -- [ ] 无需 `reset_admin`,重启服务即可拿到新密码 -- [ ] `reset_admin` CLI 仍然可用作手动备选方案 +- [ ] 命令行只输出凭据文件路径,不输出明文密码 +- [ ] 凭据文件权限为 `0600` +- [ ] 凭据文件包含 email + password 行 +- [ ] 该用户下次登录返回 `needs_setup=true` -#### TC-UPG-15: 休眠 admin 期间普通用户注册 +#### TC-UPG-15: 未初始化 admin 期间普通用户注册策略边界 ```bash -# admin 存在但从未登录,普通用户先注册 +# admin 尚不存在,普通用户尝试注册 curl -s -X POST $BASE/api/v1/auth/register \ -H "Content-Type: application/json" \ -d '{"email":"earlybird@example.com","password":"EarlyPass1!"}' \ @@ -833,11 +869,11 @@ curl -s -X POST $BASE/api/v1/auth/register \ ``` **预期:** -- [ ] 注册成功(201),角色为 `user` -- [ ] 无法提权为 admin -- [ ] 普通用户的数据与 admin 隔离 +- [ ] 当前代码允许注册普通用户并自动登录(201,角色为 `user`) +- [ ] 但 `setup-status` 仍为 `{"needs_setup": true}`,因为 admin 仍不存在 +- [ ] 这是一个产品策略边界:若要求“必须先有 admin”,需要在 `/register` 增加 admin-exists gate -#### TC-UPG-16: 休眠 admin 不影响后续操作 +#### TC-UPG-16: 普通用户数据与后续 admin 隔离 ```bash # 普通用户正常创建 thread、发消息 @@ -849,14 +885,13 @@ curl -s -X POST $BASE/api/threads \ -d '{"metadata":{}}' | jq .thread_id ``` -**预期:** 正常创建,不受休眠 admin 影响 +**预期:** 普通用户正常创建 thread;后续 admin 创建后,搜索不到该普通用户 thread -#### TC-UPG-17: 休眠 admin 最终完成 Setup +#### TC-UPG-17: reset_admin 后完成 Setup ```bash -# 运维终于登录 curl -s -X POST $BASE/api/v1/auth/login/local \ - -d "username=admin@deerflow.dev&password=" \ + -d "username=admin@example.com&password=<凭据文件密码>" \ -c admin.txt | jq .needs_setup # 预期: true @@ -866,7 +901,7 @@ 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"}' \ + -d '{"current_password":"<凭据文件密码>","new_password":"AdminFinal1!","new_email":"admin@real.com"}' \ -c admin.txt # 验证 @@ -876,7 +911,7 @@ curl -s $BASE/api/v1/auth/me -b admin.txt | jq '{email, needs_setup}' **预期:** - [ ] `email` 变为 `admin@real.com` - [ ] `needs_setup` 变为 `false` -- [ ] 后续重启控制台不再有 warning +- [ ] 后续登录使用新密码 #### TC-UPG-18: 长期未用后 JWT 密钥轮换 @@ -890,8 +925,8 @@ make stop && make dev **预期:** - [ ] 服务正常启动 -- [ ] 旧密码仍可登录(密码存在 DB,与 JWT 密钥无关) -- [ ] 旧的 JWT token 失效(密钥变了签名不匹配)— 但因为从未登录过也没有旧 token +- [ ] 账号密码仍可登录(密码存在 DB,与 JWT 密钥无关) +- [ ] 旧的 JWT token 失效(密钥变了签名不匹配) --- @@ -910,7 +945,7 @@ for i in 1 2 3; do done # 检查 admin 数量 -sqlite3 backend/.deer-flow/users.db \ +sqlite3 backend/.deer-flow/data/deerflow.db \ "SELECT COUNT(*) FROM users WHERE system_role='admin';" ``` @@ -1055,7 +1090,7 @@ curl -s -X POST $BASE/api/v1/auth/register \ wait # 检查用户数 -sqlite3 backend/.deer-flow/users.db \ +sqlite3 backend/.deer-flow/data/deerflow.db \ "SELECT COUNT(*) FROM users WHERE email='race@example.com';" ``` @@ -1165,13 +1200,16 @@ curl -s -w "%{http_code}" -X DELETE "$BASE/api/threads/$TID" \ ```bash cd backend python -m app.gateway.auth.reset_admin -# 记录密码 P1 +cp .deer-flow/admin_initial_credentials.txt /tmp/deerflow-reset-p1.txt +P1=$(awk -F': ' '/^password:/ {print $2}' /tmp/deerflow-reset-p1.txt) python -m app.gateway.auth.reset_admin -# 记录密码 P2 +cp .deer-flow/admin_initial_credentials.txt /tmp/deerflow-reset-p2.txt +P2=$(awk -F': ' '/^password:/ {print $2}' /tmp/deerflow-reset-p2.txt) ``` **预期:** +- [ ] `.deer-flow/admin_initial_credentials.txt` 每次都会被重写,文件权限为 `0600` - [ ] P1 ≠ P2(每次生成新随机密码) - [ ] P1 不可用,只有 P2 有效 - [ ] `token_version` 递增了 2 @@ -1324,7 +1362,8 @@ done ```bash GW=http://localhost:8001 -for path in /health /api/v1/auth/setup-status /api/v1/auth/login/local /api/v1/auth/register; do +for path in /health /api/v1/auth/setup-status /api/v1/auth/login/local \ + /api/v1/auth/register /api/v1/auth/initialize /api/v1/auth/logout; do echo "$path: $(curl -s -w '%{http_code}' -o /dev/null $GW$path)" done # 预期: 200 或 405/422(方法不对但不是 401) @@ -1399,9 +1438,9 @@ done > > 前置条件: > - `.env` 中设置 `AUTH_JWT_SECRET`(否则每次容器重启 session 全部失效) -> - `DEER_FLOW_HOME` 挂载到宿主机目录(持久化 `users.db`) +> - `DEER_FLOW_HOME` 挂载到宿主机目录(持久化 `deerflow.db`) -#### TC-DOCKER-01: users.db 通过 volume 持久化 +#### TC-DOCKER-01: deerflow.db 通过 volume 持久化 ```bash # 启动容器 @@ -1416,13 +1455,13 @@ 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 \ +# 检查宿主机上的 deerflow.db +ls -la ${DEER_FLOW_HOME:-backend/.deer-flow}/data/deerflow.db +sqlite3 ${DEER_FLOW_HOME:-backend/.deer-flow}/data/deerflow.db \ "SELECT email FROM users WHERE email='docker-test@example.com';" ``` -**预期:** users.db 在宿主机 `DEER_FLOW_HOME` 目录中,查询可见刚注册的用户。 +**预期:** deerflow.db 在宿主机 `DEER_FLOW_HOME` 目录中,查询可见刚注册的用户。 #### TC-DOCKER-02: 重启容器后 session 保持 @@ -1466,22 +1505,24 @@ done **已知限制:** In-process rate limiter 不跨 worker 共享。生产环境如需精确限速,需要 Redis 等外部存储。 -#### TC-DOCKER-04: IM 渠道不经过 auth +#### TC-DOCKER-04: IM 渠道使用内部认证 ```bash -# IM 渠道(Feishu/Slack/Telegram)在 gateway 容器内部通过 LangGraph SDK 通信 -# 不走 nginx,不经过 AuthMiddleware +# IM 渠道(Feishu/Slack/Telegram)在 gateway 容器内部通过 LangGraph SDK 调 Gateway +# 请求携带 process-local internal auth header,并带匹配的 CSRF cookie/header # 验证方式:检查 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 层。 +**预期:** 无 auth 相关错误。渠道不依赖浏览器 cookie;服务端通过内部认证头把请求归入 `default` 用户桶。 -#### TC-DOCKER-05: admin 密码写入 0600 凭证文件(不再走日志) +#### TC-DOCKER-05: reset_admin 密码写入 0600 凭证文件(不再走日志) ```bash -# 凭证文件写在挂载到宿主机的 DEER_FLOW_HOME 下 +# 首次启动不会自动生成 admin 密码。先重置已有 admin,凭据文件写在挂载到宿主机的 DEER_FLOW_HOME 下。 +docker exec deer-flow-gateway python -m app.gateway.auth.reset_admin --email docker-test@example.com + ls -la ${DEER_FLOW_HOME:-backend/.deer-flow}/admin_initial_credentials.txt # 预期文件权限: -rw------- (0600) @@ -1512,14 +1553,15 @@ sleep 15 docker ps --filter name=deer-flow-langgraph --format '{{.Names}}' | wc -l # 预期: 0 -# auth 流程正常 +# auth 流程正常:未登录受保护接口返回 401 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=<日志密码>" \ +curl -s -X POST $BASE/api/v1/auth/initialize \ + -H "Content-Type: application/json" \ + -d '{"email":"admin@example.com","password":"AdminPass1!"}' \ -c cookies.txt -w "\nHTTP %{http_code}" -# 预期: 200 +# 预期: 201 ``` ### 7.4 补充边界用例 @@ -1587,13 +1629,15 @@ curl -s -D - -X POST $BASE/api/v1/auth/login/local \ #### TC-EDGE-05: HTTP 无 max_age / HTTPS 有 max_age ```bash +GW=http://localhost:8001 + # HTTP -curl -s -D - -X POST $BASE/api/v1/auth/login/local \ +curl -s -D - -X POST $GW/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 \ +# HTTPS:直连 Gateway 才能用 X-Forwarded-Proto 模拟 HTTPS;nginx 会覆盖该 header +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 "access_token=" | grep -oi "max-age=[0-9]*" @@ -1712,10 +1756,10 @@ 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 + -d '{"metadata":{"owner_id":"victim-user-id","user_id":"victim-user-id"}}' | jq .metadata ``` -**预期:** 返回的 `metadata.owner_id` 应为当前登录用户的 ID,不是请求中注入的 `victim-user-id`。服务端应覆盖客户端提供的 `user_id`。 +**预期:** 返回的 `metadata` 不包含 `owner_id` 或 `user_id`。真实所有权写入 `threads_meta.user_id`,不从客户端 metadata 接收,也不通过 metadata 回显。 #### 7.5.6 HTTP Method 探测 @@ -1796,6 +1840,6 @@ cd backend && PYTHONPATH=. uv run pytest \ # 核心接口冒烟 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/setup-status # 200 curl -s $BASE/api/v1/auth/me -b cookies.txt # 200 (有 cookie) ``` diff --git a/backend/docs/AUTH_UPGRADE.md b/backend/docs/AUTH_UPGRADE.md index 344c488c4..75fe8b3cb 100644 --- a/backend/docs/AUTH_UPGRADE.md +++ b/backend/docs/AUTH_UPGRADE.md @@ -2,13 +2,16 @@ DeerFlow 内置了认证模块。本文档面向从无认证版本升级的用户。 +完整设计见 [AUTH_DESIGN.md](AUTH_DESIGN.md)。 + ## 核心概念 认证模块采用**始终强制**策略: -- 首次启动时自动创建 admin 账号,随机密码打印到控制台日志 +- 首次启动时不会自动创建账号;首次访问 `/setup` 时由操作者创建第一个 admin 账号 - 认证从一开始就是强制的,无竞争窗口 -- 历史对话(升级前创建的 thread)自动迁移到 admin 名下 +- 已有 admin 后,服务启动时会把历史对话(升级前创建且缺少 `user_id` 的 thread)迁移到 admin 名下 +- 新数据按用户隔离:thread、workspace/uploads/outputs、memory、自定义 agent 都归属当前用户 ## 升级步骤 @@ -25,39 +28,41 @@ cd backend && make install make dev ``` -控制台会输出: +如果没有 admin 账号,控制台只会提示: ``` ============================================================ - Admin account created on first boot - Email: admin@deerflow.dev - Password: aB3xK9mN_pQ7rT2w - Change it after login: Settings → Account + First boot detected — no admin account exists. + Visit /setup to complete admin account creation. ============================================================ ``` -如果未登录就重启了服务,不用担心——只要 setup 未完成,每次启动都会重置密码并重新打印到控制台。 +首次启动不会在日志里打印随机密码,也不会写入默认 admin。这样避免启动日志泄露凭据,也避免在操作者创建账号前出现可被猜测的默认身份。 -### 3. 登录 +### 3. 创建 admin -访问 `http://localhost:2026/login`,使用控制台输出的邮箱和密码登录。 +访问 `http://localhost:2026/setup`,填写邮箱和密码创建第一个 admin 账号。创建成功后会自动登录并进入 workspace。 -### 4. 修改密码 +如果这是从无认证版本升级,创建 admin 后重启一次服务,让启动迁移把缺少 `user_id` 的历史 thread 归属到 admin。 -登录后进入 Settings → Account → Change Password。 +### 4. 登录 + +后续访问 `http://localhost:2026/login`,使用已创建的邮箱和密码登录。 ### 5. 添加用户(可选) -其他用户通过 `/login` 页面注册,自动获得 **user** 角色。每个用户只能看到自己的对话。 +其他用户通过 `/login` 页面注册,自动获得 **user** 角色。每个用户只能看到自己的对话、上传文件、输出文件、memory 和自定义 agent。 ## 安全机制 | 机制 | 说明 | |------|------| | JWT HttpOnly Cookie | Token 不暴露给 JavaScript,防止 XSS 窃取 | -| CSRF Double Submit Cookie | 所有 POST/PUT/DELETE 请求需携带 `X-CSRF-Token` | +| CSRF Double Submit Cookie | 受保护的 POST/PUT/PATCH/DELETE 请求需携带 `X-CSRF-Token`;登录/注册/初始化/登出走 auth 端点 Origin 校验 | | bcrypt 密码哈希 | 密码不以明文存储 | -| 多租户隔离 | 用户只能访问自己的 thread | +| Thread owner filter | `threads_meta.user_id` 由服务端认证上下文写入,搜索、读取、更新、删除默认按当前用户过滤 | +| 文件系统隔离 | 线程数据写入 `{base_dir}/users/{user_id}/threads/{thread_id}/user-data/`,sandbox 内统一映射为 `/mnt/user-data/` | +| Memory / agent 隔离 | 用户 memory 和自定义 agent 写入 `{base_dir}/users/{user_id}/...`;旧共享 agent 只作为只读兼容回退 | | HTTPS 自适应 | 检测 `x-forwarded-proto`,自动设置 `Secure` cookie 标志 | ## 常见操作 @@ -74,22 +79,26 @@ python -m app.gateway.auth.reset_admin python -m app.gateway.auth.reset_admin --email user@example.com ``` -会输出新的随机密码。 +会把新的随机密码写入 `.deer-flow/admin_initial_credentials.txt`,文件权限为 `0600`。命令行只输出文件路径,不输出明文密码。 ### 完全重置 -删除用户数据库,重启后自动创建新 admin: +删除统一 SQLite 数据库,重启后重新访问 `/setup` 创建新 admin: ```bash -rm -f backend/.deer-flow/users.db -# 重启服务,控制台输出新密码 +rm -f backend/.deer-flow/data/deerflow.db +# 重启服务后访问 http://localhost:2026/setup ``` ## 数据存储 | 文件 | 内容 | |------|------| -| `.deer-flow/users.db` | SQLite 用户数据库(密码哈希、角色) | +| `.deer-flow/data/deerflow.db` | 统一 SQLite 数据库(users、threads_meta、runs、feedback 等应用数据) | +| `.deer-flow/users/{user_id}/threads/{thread_id}/user-data/` | 用户线程的 workspace、uploads、outputs | +| `.deer-flow/users/{user_id}/memory.json` | 用户级 memory | +| `.deer-flow/users/{user_id}/agents/{agent_name}/` | 用户自定义 agent 配置、SOUL 和 agent memory | +| `.deer-flow/admin_initial_credentials.txt` | `reset_admin` 生成的新凭据文件(0600,读完应删除) | | `.env` 中的 `AUTH_JWT_SECRET` | JWT 签名密钥(未设置时自动生成临时密钥,重启后 session 失效) | ### 生产环境建议 @@ -111,19 +120,21 @@ python -c "import secrets; print(secrets.token_urlsafe(32))" | `/api/v1/auth/me` | GET | 获取当前用户信息 | | `/api/v1/auth/change-password` | POST | 修改密码 | | `/api/v1/auth/setup-status` | GET | 检查 admin 是否存在 | +| `/api/v1/auth/initialize` | POST | 首次初始化第一个 admin(仅无 admin 时可调用) | ## 兼容性 -- **标准模式**(`make dev`):完全兼容,admin 自动创建 +- **标准模式**(`make dev`):完全兼容;无 admin 时访问 `/setup` 初始化 - **Gateway 模式**(`make dev-pro`):完全兼容 -- **Docker 部署**:完全兼容,`.deer-flow/users.db` 需持久化卷挂载 -- **IM 渠道**(Feishu/Slack/Telegram):通过 LangGraph SDK 通信,不经过认证层 +- **Docker 部署**:完全兼容,`.deer-flow/data/deerflow.db` 需持久化卷挂载 +- **IM 渠道**(Feishu/Slack/Telegram):通过 Gateway 内部认证通信,使用 `default` 用户桶 - **DeerFlowClient**(嵌入式):不经过 HTTP,不受认证影响 ## 故障排查 | 症状 | 原因 | 解决 | |------|------|------| -| 启动后没看到密码 | admin 已存在(非首次启动) | 用 `reset_admin` 重置,或删 `users.db` | +| 启动后没看到密码 | 当前实现不在启动日志输出密码 | 首次安装访问 `/setup`;忘记密码用 `reset_admin` | +| `/login` 自动跳到 `/setup` | 系统还没有 admin | 在 `/setup` 创建第一个 admin | | 登录后 POST 返回 403 | CSRF token 缺失 | 确认前端已更新 | | 重启后需要重新登录 | `AUTH_JWT_SECRET` 未持久化 | 在 `.env` 中设置固定密钥 | diff --git a/backend/docs/README.md b/backend/docs/README.md index da566005d..27e33f854 100644 --- a/backend/docs/README.md +++ b/backend/docs/README.md @@ -8,6 +8,7 @@ This directory contains detailed documentation for the DeerFlow backend. |----------|-------------| | [ARCHITECTURE.md](ARCHITECTURE.md) | System architecture overview | | [API.md](API.md) | Complete API reference | +| [AUTH_DESIGN.md](AUTH_DESIGN.md) | User authentication, CSRF, and per-user isolation design | | [CONFIGURATION.md](CONFIGURATION.md) | Configuration options | | [SETUP.md](SETUP.md) | Quick setup guide | @@ -42,6 +43,7 @@ docs/ ├── README.md # This file ├── ARCHITECTURE.md # System architecture ├── API.md # API reference +├── AUTH_DESIGN.md # User authentication and isolation design ├── CONFIGURATION.md # Configuration guide ├── SETUP.md # Setup instructions ├── FILE_UPLOAD.md # File upload feature