ARTICLE · 1357236
FUXA 源码解读 ⑨|REST API 与鉴权
01
一句话结论
FUXA 的 REST 鉴权不是"门口拦人",而是"门口放行、屋里分房":中间件几乎不返回 401,它只负责把每个请求认成某个身份(最差是 guest),真正的权限判断分散在 14 个子路由和一次数据出口裁剪里。更要注意的是,secureEnabled 在默认配置里是注释掉的,关掉时所有请求等价于管理员。
02
入口与调用链
挂载点在启动链末端:main.js:363 调 FUXA.init(server, io, settings, logger, events),fuxa.js:24 里 api.init(httpServer, runtime),最后由 main.js:636 把 FUXA.httpApi 挂到 BASE_PATH + '/' 上。也就是说 REST 与静态资源共用同一个 HTTP 服务,区别只在于路由前缀。
api/index.js:36-98 的 init 干五件事,顺序很关键:
- bodyParser.json({limit})
,默认 100mb(:49),所以大项目整包 POST 是设计内的; - authJwt.init(secureEnabled, secretCode, tokenExpiresIn)
(:52),把安全开关和签名密钥注入 JWT 模块; - const authMiddleware = verifyApiOrToken(runtime)
(:53),生成那个"永不 401"的中间件; 两个限流器(:55-69):authLimiter 只对 /api/signin、/api/refresh 生效(skip 里把其它路径全跳过),limiter 覆盖全部 API,默认 1000 次 / 5 分钟;注释明说"挂在路由之前,才能覆盖子路由"; 依次 use 十四个子 App:projects、users、alarms、auth、plugins、diagnose、daq、scheduler、recipes、scripts、resources、command、reports、apikeys。
一个请求的实际链路是:
Express 路由 → secureFnc(=verifyApiOrToken) → verifyGroups → 业务函数 ↓ runtime.project / devices / users …
子路由里每个 handler 第一行几乎都是 const permission = checkGroupsFnc(req),然后 if (!authJwt.haveAdminPermission(permission)) 直接 401 —— 这个模式在 users、apikeys、command、projects 里逐字重复,是全仓库最统一的写法。

一次 REST 请求的五道关
▲ 同一张图里其实有两条路:外层是"永远放行"的中间件,内层才是真正判权限的路由;坏 token 不会出门,只会被降级成 guest。
03
核心数据结构
鉴权只有两张表、两种凭据:
| users | users.fuxap.db | username | |
| roles | name | value | |
| apikeys | apikeys.fuxap.db | name | value |
| jwt-helper.js:110-115 | {id, groups} | ||
| auth/index.js:58-60 | {id, type:'refresh'} | ||
| req | jwt-helper.js:69-76 | userId |
groups 是个整数,语义在 runtime/index.js:788-854 才完整:-1、255 是管理员(jwt-helper.js:10 的 adminGroups);普通组号当位掩码用,低 8 位管 enabled、高 8 位管 show(:851-854)。角色模式(settings.userRole)下则改成比对 info.roles 数组(:832-844)。同一个 groups 字段承担两种语义,这是理解后半篇所有权限分支的前提。
首次启动会自动建一个管理员:usrstorage.js:121-132,bcrypt.hashSync('123456', 10)。
04
关键流程
4.1 三种凭据怎么落到同一个身份
无 token:jwt-helper.js:62-64 先 getGuestToken(),用服务端密钥现签一个 {id:'guest', groups:['guest']}(:118-127),再走正常验签。绕这一圈是为了让下游永远拿到结构完整的 decoded,不用到处判空。
坏 token / 过期 token:验签失败不返回 401,而是写 req.userId='guest'、req.isAuthenticated=false 然后 next()(:67-78)。所以"token 过期"在现场的表现不是报错,而是功能悄悄降级。
API Key:verify-api-or-token.js:15-37 先查 x-api-key,命中后一句话把它提成管理员 —— req.userGroups = authJwt.adminGroups[0](:35)。而且只要带了 key 但无效,就直接 401,不会退回 JWT(:42),两个头同时携带时这是坑。
4.2 安全开关关掉之后
verifyApiOrToken 第一行 if (!runtime?.settings?.secureEnabled) return next()(:11-13);verifyGroups 在关闭时直接返回 -1(api/index.js:377-379)。两个函数配合的结果是:关掉安全 = 所有人都是管理员,包括 /api/users、/api/apikeys、/api/settings 这些最敏感的接口。而 settings.default.js:115 里 secureEnabled 是被注释掉的,也就是说开箱状态就是这个"全管理员"模式。
4.3 权限不拦入口,而是裁数据
这是本篇最值得记住的设计。_filterProjectPermission(project/index.js:1166-1246)在返回项目数据之前动手:
无权用户拿不到 server 配置(:1175); 脚本列表只留 isAuthorised 通过的;服务器脚本删掉 code 只留元数据,CLIENT 脚本保留源码(:1180-1190); 导航项、头部项逐项 checkPermission,不合格直接 splice(:1193-1208); 视图控件:show=false 时在 svgcontent 字符串里找到元素 id、插一段 visibility="hidden" (:1217-1223);enabled=false 时清空 property.events 并给 input/select/button 加 disabled(:1224-1235)。
所以"这个用户看不到某个按钮"不是前端判断的,是服务端在 SVG 字符串里挖了个洞。好处是前端零信任成本,坏处是权限逻辑和渲染格式绑死 —— 换一种视图格式就得重写一遍。
4.4 管理面与刷新
/api/settings 的 GET 分两档:管理员拿 getSanitizedSettings(删 secretCode、https、smtp.password、daqstore.credentials,api/index.js:256-268),非管理员拿 getPublicSettings,再额外删掉 smtp 的 host/port/username 与 daqstore 的 url/host/连接串(:241-254)。POST 则只认管理员,且在写盘前会把旧的密码、密钥、连接凭据回填(:146-158),避免前端不传就清空。
/api/refresh 只认 HttpOnly cookie(auth/index.js:200-249),cookie 名 fuxa_refresh、path 锁在 BASE_PATH + '/api/refresh'(:62-74),令牌里带 type:'refresh',用错了会被清 cookie。/api/signin 用 bcrypt.compareSync,失败一律同一个 401 文案(:93-95、:178-185),不区分"用户不存在"和"密码错"。
/api/heartbeat(api/index.js:194-232)是权限生效时间的答案:已认证用户会用 runtime.users.getUsers重新查库拿 groups(:209-220),所以改角色之后,客户端下一次心跳就换新权限,不用重新登录。
登录口还做了两件容易被忽略的事:/api/signin 强制要求 username 是字符串(auth/index.js:152-156),注释写明"否则存储层会退化成用第一行用户去比对";/api/refresh 在安全关闭或没开 refresh cookie 时直接返回 204(:201-203),不报错。
4.5 错误约定
整套 API 的错误码很省:权限不足一律 401,几乎不用 403;只有请求体超限会被中间件拦成 413(api/index.js:100-107,limit 取自 apiMaxLength,默认 100mb);API Key 查库异常才会返回 500(verify-api-or-token.js:38-41)。多个路由里写着 if (res.statusCode === 403) 的分支(command/index.js:122-123、api/index.js:139-140),但 secureFnc 从不把状态码设成 403(全仓库 403 只出现在 resources/index.js:330 和 Node-RED 挂载里),所以这段判断在自己的链路上永远不成立 —— 它本来是想处理"token 过期",而 token 过期在这个设计里根本走不到路由。
05
设计取舍与坑
永不 401 的中间件,是"用户体验优先"的取舍。 前端不用处理"401 跳登录"的分支,代价是所有权限问题都表现为"页面少了一块",服务端日志里只有一句 Unauthorized。排障时先看 x-access-token 有没有带上、有没有过期。
回扣 D-04:同一张权限表,REST 严、socket 松。 REST 的写值入口 /api/setTagValue 要求 haveAdminPermission(command/index.js:124),guest 直接 401;但 UI 实际走的是 socket 的 DEVICE_VALUES cmd=set,它的闸门是 isSocketWriteAuthorized(runtime/index.js:40-45),而这个函数第一行就是 if (!settings || !settings.secureEnabled || settings.secureOnlyEditor) return true。secureOnlyEditor 的本意是"只要求编辑器登录,看画面不用登录",实际把写值也一起放开了 —— 详见 04 篇 D-04,这里不再展开。两条路径不是两个 bug,而是"连接期判定"与"路由期判定"分家之后,开关只挂在其中一边。

三条写值路径,三种闸门
▲ REST、socket、脚本各走一套闸门,secureOnlyEditor 只影响中间那条,这是 D-04 的根因。
API Key 是管理员通行证,不是分权凭证。verify-api-or-token.js:35 直接把 key 映射成 adminGroups[0],verifyGroups 里又对 req.apiKey 再确认一次(api/index.js:366-367)。表里虽然有 name、expires、enabled 字段,但没有 groups,所以做不出"只读 key"。给第三方开的集成 key,等价于给它一个管理员。
key 没有缓存。_loadApiKeys(runtime/apikeys/index.js:102-115)整个函数体都被注释掉了,apiKeysMap 永远是空 Map,getApiKeysMapCache 也就永远返回 undefined。真正生效的是每次请求都 SELECT value FROM apikeys 全表读(verify-api-or-token.js:18)。点位少、调用少时无感;如果有人拿 key 做 1 秒级轮询,SQLite 读会被放大。这是取舍不是 bug,但要知道性能拐点在哪。
req.tokenExpired 是个死分支。verifyGroups 读了它(api/index.js:369),可全仓库没有任何地方给它赋值 —— 因为过期 token 在 verifyToken 里就降级成 guest 了,根本走不到这一支。读代码时别被它误导。同一性质的还有 jwt-helper.js:87-103 的 requireAuth:它会老老实实返回 401,也导出了(:158),但没有任何路由用它,实际生效的永远是 verifyToken。想知道"为什么我的 401 不出现",答案就在这里。
位掩码和角色模式别搞混。show 在高 8 位、enabled 在低 8 位(runtime/index.js:851-854)。写反了的现象很典型:按钮看得见但点不动(show 给了、enabled 没给),或者反过来"点得动但看不见"。用角色模式时则是另一套逻辑,permissionRoles 里 show/enabled 各是一个角色数组(:834-841)。
缓存与心跳会短暂打架。 路由内的判定读的是内存里的 usersMap(users/index.js:196-198),只有 _loadUsers(:200-213)才刷新;而心跳是直接查库(api/index.js:348)。所以改完角色,最先反映变化的是心跳返回的 groups,checkPermission 那一侧要等缓存刷新才跟上。排查"权限改了但页面没变"时,先确认是缓存还是客户端没重拉项目。
默认口令必须在上线前处理。usrstorage.js:124 首次建库写死 admin / 123456,且 secureEnabled 默认关闭,这两条叠在一起就是"开局即后台"。
06
小结 + 下一篇
三句话带走:
FUXA 的 REST 鉴权是"身份必给、权限分处判",中间件几乎不拦人,401 都发生在具体路由里; 权限的落点是数据出口(_filterProjectPermission 裁项目)而不是入口,所以视图级权限能精确到控件; API Key 一律等于管理员,secureEnabled 默认关闭,这两点是部署时最常见的安全漏洞来源。
下一篇 10 集成与部署 换个视角:从"谁能进来"转到"装在哪、怎么活下去"—— Docker 三阶段镜像、Electron 的 fork 单进程、headless 的守护双模式,以及 Node-RED 是怎么挂到同一个 HTTP 服务上的。















































































