夜雨聆风学习资料网

ARTICLE · 1357236

FUXA 源码解读 ⑨|REST API 与鉴权

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 干五件事,顺序很关键:

  1. bodyParser.json({limit})
    ,默认 100mb(:49),所以大项目整包 POST 是设计内的;
  2. authJwt.init(secureEnabled, secretCode, tokenExpiresIn)
    (:52),把安全开关和签名密钥注入 JWT 模块;
  3. const authMiddleware = verifyApiOrToken(runtime)
    (:53),生成那个"永不 401"的中间件;
  4. 两个限流器(:55-69):authLimiter 只对 /api/signin、/api/refresh 生效(skip 里把其它路径全跳过),limiter 覆盖全部 API,默认 1000 次 / 5 分钟;注释明说"挂在路由之前,才能覆盖子路由";
  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
(usrstorage.js:59、建表 :69)
username
 PK、fullname、password、groups、info
密码用 bcrypt 存储(:165);info 是 JSON 文本,装 roles 数组
roles
 表
同库(usrstorage.js:70)
name
 PK、value
value
 是整条角色对象的 JSON(setRoles:229-233)
apikeys
 表
apikeys.fuxap.db
(apiKeysStorage.js:45、建表 :55)
name
 PK = key 的 id、value
value
 是 {id, name, key, enabled, expires} 的 JSON
JWT 载荷
jwt-helper.js:110-115
、auth/index.js:55
{id, groups}
只有用户名和组号,不装权限明细
刷新令牌
auth/index.js:58-60{id, type:'refresh'}
靠 type 与访问令牌区分
req
 上的注入
jwt-helper.js:69-76
、verify-api-or-token.js:32-35
userId
、userGroups、isAuthenticated、apiKey
后续所有判定只读这四个字段

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

小结 + 下一篇

三句话带走:

  1. FUXA 的 REST 鉴权是"身份必给、权限分处判",中间件几乎不拦人,401 都发生在具体路由里;
  2. 权限的落点是数据出口(_filterProjectPermission 裁项目)而不是入口,所以视图级权限能精确到控件;
  3. API Key 一律等于管理员,secureEnabled 默认关闭,这两点是部署时最常见的安全漏洞来源。

下一篇 10 集成与部署 换个视角:从"谁能进来"转到"装在哪、怎么活下去"—— Docker 三阶段镜像、Electron 的 fork 单进程、headless 的守护双模式,以及 Node-RED 是怎么挂到同一个 HTTP 服务上的。

回首页,搜索你想要的文档

在首页搜索框输入关键词,快速定位资料

文库首页搜索指引 立即搜索

相关学习资料