38 · 登录鉴权 + 公司级多租户隔离 — 实现计划
日期:2026-08-25 触发:代码要部署到远程服务器,现状零鉴权——任何人打开域名直接进系统,
:companyId全部路由零权限校验,谁都能看/改任何一家公司。用户要求:登录注册(种子一个默认账号)+ 全站 cookie 鉴权 + 每个用户只看得到自己的 company(system agent 全局共享不受影响)+ 每个用户的聊天记录互相隔离。 前置结论(已在同一会话里做过一次调研,详见对话记录,不重复展开):这不是从零建鉴权系统。server/routes/auth.js+server/middleware/jwt-auth.js已经是一套完整可用的注册/登录/JWT-cookie/GitHub+Google OAuth 实现,/ws/claude的 WebSocket 鉴权也已经做完了。真正缺的是两件事:①把这套已有的锁挂到目前完全不设防的路由上;②公司归属这个概念从 0 到 1 新建(company.json目前连owner字段都没有)。 已拍板的产品决策:存量公司(data/companies/下现有的全部)迁移时统一划给种子默认账号;种子账号admin/hex1234(首次部署后建议改密,见 §七)。 状态:实现计划,未开工。
一、现状盘点(本次调研的精确定位,含文件:行号)
1.1 已经有、不用动的部分
- 认证核心:
server/routes/auth.js(279 行)——POST /register(bcrypt.hash,SALT_ROUNDS)、POST /login(bcrypt.compare)、POST /logout、GET /me、GitHub/Google OAuth 全套,真实可用。 - JWT + Cookie 中间件:
server/middleware/jwt-auth.js——signAndSetCookie(httpOnly、sameSite:'lax'、生产环境secure:true、7 天过期、cookie 名lemon_token)、extractUser(先读 cookie 再 fallbackAuthorization: Bearer)、requireAuth/optionalAuth/requireAdmin三个中间件。生产环境JWT_SECRET未设直接拒绝启动(第 24-33 行),开发环境有兜底密钥但每次启动打警告。 - 用户表:
server/services/user-db.js:37-51,users表已有id/email/password_hash/display_name/avatar_url/provider/provider_id/github_login/role/api_key/created_at/last_login,字段齐全,不用改 schema。 - WebSocket 鉴权:
server/routes/claude-chat-ws.js:396-411握手阶段解析Cookieheader、验证 JWT,未授权直接 close(4001)。同源部署(Vite dev 代理/api+/ws到 :3010,生产环境同一个 Express 进程托管前后端)下浏览器会自动带上 cookie,前端不需要为 WS 连接单独处理鉴权。 - 已经挂了
requireAuth/optionalAuth的路由:claude-chat.js、innovator.js、master-chat.js、users.js(仅 admin)——这几个文件里req.user.id的用法(尤其claude-chat.js用req.user.id隔离上传目录 + 每个 session 校验session.userId !== String(req.user.id))是一份现成的、已经做对的参考实现,公司归属校验直接照这个模式抄,不用重新设计。
1.2 完全零防护的部分
server/index.js:82-99 挂载的路由里,除上面 4 个之外全部零 requireAuth:agent-chat.js、company.js、okr.js、tasks.js、devloop.js、graph-run.js(含它在 /api/company 下的第二个挂载点 .companyRouter,server/routes/graph-run.js:571)、arena.js、config.js、evolution.js、dream.js、whatsapp-auth.js。open-api.js 是个例外——它自己有一套独立的 OPEN_API_KEYS 鉴权(面向外部 Skill 市场调用方),必须显式排除在 cookie 鉴权闸门之外,不能被顺手扫进去。
1.3 公司归属:真正的 0→1
server/services/company-service.js 全程没有 owner/user_id 概念:
get(companyId)(约 300 行)直接读data/companies/{id}/company.json,list()(308 行起)无条件遍历DATA_DIR下所有目录返回全部公司。create({id, name, goal, description})(243 行)不接收 owner 参数。- 没有
companiesSQL 表——公司身份完全是文件系统 +company.json,不是 DB 行。这点利好这次改造:加ownerId字段是往一个 JSON 文件里加一个键,不需要写 SQL migration。
:companyId 作为路由参数被信任的范围比"只有 /list"大得多:company.js 一个文件就有 50+ 条 /:companyId/... 路由(goals/tasks/hire/fire/org-graph/pipelines/files/...)。
1.4 两个容易漏掉的边角
server/routes/devloop.js:路由用的是/projects/:pid/...,不是:companyId——URL 上看不出公司归属。但项目创建时强制要求company_id(devloop.js:46-48),path-resolver.js也按pid → {root_path, mode, company_id}缓存(path-resolver.js:74,87,218-219,317)——归属信息在,只是要多一跳:校验:pid时得先查出它的company_id,再判断这个 company 是不是当前用户的,不能直接复用company.js那种"路由参数本身就是 companyId"的中间件写法。server/routes/okr.js(挂载于/api/okr,前端消费方是OkrDashboard.vue)是一套独立的、目前完全没有公司维度的全局 OKR 视图——OkrService类本身支持按companyId分目录存储(okr-service.js:28-33),但okr.js:20实例化时是new OkrService(),没传 companyId,落在"向后兼容"的全局模式,读写的是data/okr/,跟任何公司都不挂钩。这是一个此前就存在、这次调研才浮出水面的独立缺口,不是这次改造引入的——要不要把它并入这次的公司隔离范围,见 §六。
二、目标与非目标
目标:
- 未登录访问任何页面 → 跳转登录页;登录后正常使用。
- 每个普通用户只能看到/操作自己名下的 company(含其 goals/tasks/pipelines/devloop 项目/graph-runs/聊天记录)。
system/agents/全局共享,不受影响(本来就是文件系统级共享,天然满足,见 §一验证过)。- 种子一个默认账号
admin,密码hex1234,存量公司全部归它。
非目标(这次不做,未来需要再单独立项):
- 不做团队/组织级共享(一个 company 只属于一个 user,不支持"邀请协作者")。
- 不重新设计 GitHub/Google OAuth(已经能用,保持现状,登录页并列展示"账号密码登录"和"第三方登录"两个入口即可)。
- 不处理
/api/open/*的 Skill 市场访问控制(它自己的OPEN_API_KEYS体系是独立需求)。
三、实现方案
3.1 归属校验的统一收口模式
每个持有 :companyId 的 router 用 router.param('companyId', ...) 收口一次,而不是逐条路由里手写检查——这样"新增一条路由忘了查权限"这类问题在写路由的时候就不可能发生(少了一步就报错,不是少了一步就悄悄放行):
router.param('companyId', (req, res, next, companyId) => {
const company = companyService.get(companyId);
if (!company) return res.status(404).json({ error: `公司 ${companyId} 不存在` });
if (req.user.role !== 'admin' && company.ownerId !== req.user.id) {
return res.status(404).json({ error: `公司 ${companyId} 不存在` }); // 404 不是 403:不暴露"这个 id 存在但不是你的"
}
req.company = company;
next();
});
返回 404 而不是 403 是刻意的:403 会告诉攻击者"这个 company id 是有效的,只是你无权访问",等于把 id 空间泄漏出去;未登录/无权都统一表现为"不存在"。
company.js、graph-run.js(两个挂载点都要加)都走这个模式。okr.js/tasks.js 目前不接 :companyId 路由参数(见 §一.4),需要看 §六 的决定再定具体接入方式。
3.2 devloop.js 的间接校验
router.param('pid', ...) 里先用 path-resolver 查出 company_id,再复用 3.1 同一份归属判断逻辑(抽成共享函数,不要复制一份判断代码到两个地方)。
3.3 前端
- 新增登录页(账号密码表单 + 第三方登录按钮,直接调用已有的
/api/auth/login、/api/auth/github)。 web/src/router/index.js加全局beforeEach:调GET /api/auth/me(optionalAuth,不 401)判断登录态,未登录且目标路由非登录页 → 重定向登录页。- 一个全局的 fetch/axios 拦截器:收到 401 → 清本地状态、跳登录页(避免每个页面各自处理一次 401)。
- 退出登录入口(调
POST /api/auth/logout清 cookie)。
3.4 种子账号
不新增脚本,直接复用已有的 POST /api/auth/register(或等价的一次性 seed 脚本包一层 userDb.createUser + bcrypt.hash('hex1234', SALT_ROUNDS)),部署时跑一次。
3.5 存量数据迁移
一次性脚本:遍历 data/companies/*/company.json,无 ownerId 字段的统一写入种子账号的 user.id。脚本本身是幂等的(ownerId 已存在就跳过),可以放心重复跑。
四、分 Phase 实施计划
每个 Phase 独立可验收,不依赖后一个 Phase 就能上线(参考 27-p2-org-graph.md 的分阶段惯例)。
Phase 0 — 种子账号 + 登录页跑通(不改任何业务路由)✅ 已完成(2026-08-25)
scripts/seed-admin-user.js(npm run seed:admin)——幂等,建出admin/hex1234、role=admin。已在本机跑过,账号id=396。- 前端:
web/src/views/Login.vue(登录页)+web/src/composables/useAuth.js(登录态 +checkAuth/login/logout/clearAuth)+router/index.js的beforeEach守卫(未登录 → 跳/login?redirect=...,登录后带着原目标跳回)+main.js的全局 401 拦截器(中途 cookie 过期同样兜住,不止页面切换那一刻)+AppNav.vue加了用户名/退出按钮。 - 顺手修了一处过程中发现的泄漏:
App.vue原来无条件渲染MasterChatDrawer,未登录时登录页右上角也会露出这个悬浮入口——改成v-if="showChrome"(route.name !== 'Login' && !!user),登录页干净了。 - 验收(真实浏览器走过一遍,非仅测试通过):未登录访问
/company等任意页面 → 跳/login?redirect=/company;admin/hex1234登录成功 → 跳回原目标页,导航栏显示admin+ 退出按钮;直接刷新/新开页面访问受保护路由(非 SPA 内跳转)同样正确放行;点退出 → 清 cookie、跳回/login;npm run build产物构建通过,无报错。因为业务路由还没加锁,登录后依然能看到现状全部数据(等价于"登录墙搭好了,屋里还没上锁"),符合这一阶段的验收边界。
Phase 1 — 全站要求登录(不做归属过滤)
- 给
agent-chat.js/company.js/okr.js/tasks.js/devloop.js/graph-run.js(两个挂载点)/arena.js/config.js/evolution.js/whatsapp-auth.js挂requireAuth,显式排除open-api.js、auth.js自身、/api/health。 dream.js挂requireAdmin而不是requireAuth(已决策,见 §五)——它是系统级的经验汇流视图,只有 admin 能看,不是"登录即可看"。- 验收:未登录状态下所有 API 都 401;已登录状态下行为跟 Phase 0 完全一致(任何登录用户依然能看到全部公司——归属过滤还没上);非 admin 登录用户访问
dream.js下的接口 403。这一步的回归测试很简单:跑现有测试套件 + 确认没有新增的越权点。
Phase 2 — 公司归属隔离(这次真正的硬骨头)
company.jsonschema 加ownerId;create()接收 owner 参数(从req.user.id传入);list()按req.user过滤(admin 角色不过滤)。- 存量数据迁移脚本跑一遍(§3.5),全部划给种子账号。
company.js/graph-run.js两个挂载点接入 3.1 的router.param('companyId', ...)统一校验。devloop.js接入 3.2 的间接校验。okr.js(/api/okr,独立于company.js的那套遗留全局 OKR)纳入这次范围(已决策,见 §五):改成接收 companyId(路由参数或 query),OkrService按 companyId 实例化(okr-service.js本来就支持,只是没接),同样过 3.1 的归属校验;OkrDashboard.vue前端跟着改成需要先选公司。注意company.js自己的okrPath()系列路由(/api/company/:id/okr,CompanyDashboard.vue团队页 OKR tab 用的是这一套)已经在这一步随company.js一起被router.param('companyId')收口,不需要额外工作——真正缺的只是这个独立的遗留/api/okr挂载点。- 验收:种子账号登录看到全部存量公司(无缝衔接);新注册账号登录看到空列表;新账号尝试直接拼 URL 访问种子账号的 company id → 404;新建的 company 只有创建者自己看得到;
/okr页面按公司切换后数据不串。这一步要重点测"漏了一条路由"这类问题——建议写一个守卫测试,遍历company.js/graph-run.js/okr.js里的路由定义,断言每一条都经过了router.param('companyId')这个收口点(而不是逐条手工检查,人工抽查容易漏)。
Phase 3 — 聊天记录隔离验证 + 前端收尾体验
- LemonBoss 侧会话(
data/companies/{id}/agents/{agentId}/{convId})理论上 Phase 2 做完就自动隔离了(会话挂在 company 目录下),这一步只是验证,不是新写代码:确认没有遗漏的、绕过 company 归属校验就能读会话的接口(比如某个直接按convId查、不经过companyId校验的路由)。 - 前端补全登出后的空状态、加载态、错误提示;确认 18 个路由页面在未登录/无权访问公司时的表现符合预期。
- 验收:跨账号真实操作一遍——种子账号和新账号分别登录,交叉尝试访问对方的会话记录,确认全部拒绝。
五、开放问题(已拍板)
okr.js的全局OkrService要不要并入这次范围?→ 并入。 用户明确要求"OKR 跟着 company 走,需要隔离"。已更新 §四 Phase 2:okr.js改成接收 companyId 并按其实例化OkrService(本来就支持,只是没接),过同一套归属校验;OkrDashboard.vue前端跟着改成需要先选公司。company.js自己的/api/company/:id/okr(CompanyDashboard.vue团队页 OKR tab 在用)本来就在company.js里,Phase 2 对company.js做router.param('companyId')收口时顺带就隔离了,不算独立工作量。- 首次登录是否强制改密 → 不需要。 种子密码
hex1234保持明文可用,不加强制改密流程。 arena.js/evolution.js→ 确认为疑似废弃代码,不投入隔离工作量。 核实结果:web/src/全局搜索零处引用/api/arena(注意与open-api.js下仍在用的/api/open/arena/leaderboard是两个不同文件,后者继续保留)、零处引用/api/evolution;两个文件最后一次改动都停在2026-03-23(比仓库其余部分老得多)。Phase 1 按最省事的方式处理:一律挂requireAuth(登录即可访问,不单独判断是否该删),不额外调查、不纳入这次范围——真要确认能不能删是另一件事,不属于这次鉴权改造。dream.js→ 系统级,只有 admin 能看。 已更新 §四 Phase 1:挂requireAdmin而不是requireAuth。config.js归属未定:现状调研没有深入,且用户这轮没有明确指示。Phase 1 先按arena.js/evolution.js同款处理——挂requireAuth,不做归属过滤,留到看内部是否有 company 维度数据时再决定要不要提级。
六、风险
- 归属校验漏挂——见 §一.2/§四 Phase 2 的守卫测试建议,这是本次最大的风险点,一条路由漏挂 = 直接的越权数据读写。
graph-run.js的双挂载点——server/index.js:97-98同一个文件以两种方式挂载(/api/graph-runs全量 +/api/company下的.companyRouter子集),改的时候容易只改一处。devloop.js的间接校验容易被绕过——如果新增一条 devloop 路由时忘了走:pid收口点(比如直接接收company_id作为 body 参数而不是从已归属校验过的:pid反查),归属检查会被绕过。建议 code review 时专门盯这一类新路由。- admin 角色的越权范围——
requireAdmin/role==='admin'目前语义是"看得到所有公司",这次种子账号本身就是 admin,等于事实上的超级账号;如果以后要开放更多真实用户注册,需要明确"什么样的用户会被设成 admin"这条边界,避免误操作把普通用户升成了能看所有公司的角色。