ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

ROS Web认证实战:基于Nginx+JWT的完整信任链

ROS Web认证实战:基于Nginx+JWT的完整信任链

简介:面向需要在 RouterOS(ROS)中部署 Web 认证热点的网络管理员,这份压缩包提供了一整套可直接使用的 ROS hotspot 认证页面模板与配套素材。包含登录、认证成功、注销、错误、重定向等完整流程页面,以及用于页面样式与脚本调用的 CSS、JS 文件,还配有多种格式的图片和小动画,可据此快速替换默认认证界面,加入品牌标识或服务条款,实现更规范的热点登录体验。

资源共 71 个文件,其中以 HTML 页面(19 个)和 JPG/PNG/GIF 图片素材为主,另有少量 txt 说明、CSS/JS/XSD 配置文件,压缩包整体仅 95KB,便于直接导入 ROS 的 Files 目录进行覆盖或修改。对这些页面和脚本进行简单定制,即可理解热点认证在 AL/SL/FL 等状态下的跳转逻辑,并掌握 login.html、logout.html、error.html 等关键模板的作用。

已有 306 人学习下载,适合具备一定 ROS 基础、希望从零搭建或美化 Web 认证入口的网络运维者和技术爱好者。

1. ROS Web认证到底是什么?先回答“谁有资格动这台机器人”

ROS Web认证听起来像是“给ROS网页加个登录框”,但真正要解决的是:浏览器从Web页面向机器人发指令时,中间隔着 HTTP、WebSocket、rosbridge 多层通道,每一层都需要确认“是谁在发指令、有没有权限发这条指令”。我经手的某室内巡检机器人项目(下称模拟项目X)第一次暴露Web控制台时,局域网内任何一台电脑都能通过浏览器让底盘电机转起来,那一刻我就确认了“登录页”只是面子工程,ROS Web认证要解决的是从浏览器到ROS节点的完整信任链。

这套方案面向的不是“做个Demo给自己看”的场景,而是给多台机器人加共享Web接口、无人值守站点、或者把机器人控制能力开放给内部系统的从业者。难点不在于签发一个Token,而在于Token要穿过Nginx网关、WebSocket握手、JSON-RPC消息三个环节,每个环节都有各自的坑。接下来我会按“先选层、再落地、后排错、最后提权”的顺序,把这套链路完整讲透。

2. 先想清楚:ROS Web认证到底该做在哪一层

先说一个结论:不要一上来就在每个ROS节点里加鉴权函数,那样会把一个认证问题拆成几十个打补丁的问题。ROS Web认证面对的是“谁能用Web控制机器人”,这个边界天然在ROS通信之外,所以大多数情况下应该把认证做在流量进入ROS之前的路径上。

ROS侧常见的Web接入方式有两种:一种是网页直接调用机器人上的HTTP接口,比如查询电池、地图、日志;另一种是通过 rosbridge_server 提供 WebSocket 通道,网页订阅话题、发布控制指令、调用服务。两条路径的流量都可以在进入ROS之前,由一道统一认证网关拦截。

2.1 为什么我建议从网关层入手,而不是第一天改ROS节点

ROS节点内部做鉴权往往意味着每个订阅、发布、服务回调里都要插入身份判断代码。更重要的是,ROS节点数量和类型会影响鉴权逻辑的一致性:你给底盘节点加了鉴权,导航节点、地图节点、日志节点也都要加一遍,而且每台的ROS版本和通信机制还有差异。

网关层则完全不同。它把认证收敛到一个入口,所有HTTP请求和WebSocket握手都必须经过它。节点层代码零改动,机器人跑多长演进不慌。网关层的代价是控制粒度比较粗——它只能判断“这个请求是否来自已登录的人”,不能判断“这个人能不能发布 /cmd_vel”。但这句话反过来也成立:网关层能挡住绝大多数匿名访问和粗暴扫描,先把底兜住。

我一般会这样分层:用网关层做基础认证,挡住外来流量;在关键控制话题上再做一层消息级鉴权,防止内部账号越权。两层配合,认证是前提,授权是深度控制。很多项目死在第一天就想做“完美的细粒度授权”,结果连基础登录都没跑通。

2.2 三层选型对比:把控粒度、改动量与落地成本放到一张桌上

体现实操选型,建议直接把选项列成对照表,按自家需求勾选:

方案改动量控制粒度WebSocket友好度典型适用场景
Nginx/网关层校验小,只需配置和独立auth服务粗,只能控制到路由级别中,WebSocket握手可拦截,长连接建立后不再校验快速封住匿名访问,保护地图、日志等HTTP接口
rosbridge消息代理/插件中,需要在消息通道上做过滤细,可以精确到每个 topic 和 service高,直接在长连接内逐条消息鉴权需要控制谁能发布 /cmd_vel、谁能订阅 /map
ROS节点内校验大,每个节点都要改最细,可以结合业务逻辑判断低,主要靠节点自身防御安全要求极高、节点本身就需要独立权限体系

决策逻辑可以简化成三步:如果机器人还处于产品验证期,先上网关层,成本最低;如果确认存在多角色使用场景,比如操作员和访客看同一个地图但只有操作员能发指令,再加一层消息代理;节点内校验可以等业务功能稳定后再补,不要一开始就铺开。

2.3 Token载荷设计:别人拿到的JWT里到底应该有什么

选定网关层方案后,Token本身的设计要提前想清楚。我见过不少项目把JWT当“临时通行证”,只放user_id和exp,后面想加权限的时候才发现所有客户端都过期不了,只能等token自然失效,非常被动。

机器人场景我建议载荷至少包含这几项:

{ "iss": "robot-web-auth", "sub": "operator-001", "aud": "robot-gateway", "exp": 1713000000, "iat": 1712996400, "scope": "cmd_vel:pub;map:sub;battery:sub" }

逻辑说明:iss标识签发方,方便多机器人集群时快速知道token来源;sub是操作员ID,也是后续追踪操作行为的核心字段;aud告诉网关这张token是发给哪个机器人网关的;exp和iat是一对,必须同时有,没有iat的话,刷新机制很难做;scope是我最看重的字段,它不等同于“角色”,而是直接列出允许访问的话题和操作,这样后续消息代理做话题级授权时可以直接解析它,不用再拉一次数据库。

签名算法上的建议是:单台机器人、单网关,用 HS256 足够;如果未来会有多台网关、或者需要独立鉴权服务器统一验签,再上 RS256。RS256 的优势是私钥只在签发端,网关只持有公钥,泄露一个网关不会拖垮整个签发体系;缺点是密钥管理复杂度更高。小项目别一上来就上RS256,先把HS256的密钥保管好,比什么都强。

注意:HS256的密钥一旦泄露,等于任何人都能伪造token。密钥不要写死在代码里,放到环境变量或者机器人本机单独的配置文件里,并且保证只有运行机器人服务的账号能读。

3. 在Nginx网关层跑通JWT校验:auth_request的最小可运行配置

选好方案之后,最直接的落地方式就是Nginx的auth_request模块。它的逻辑很简单:收到外部请求后,先发一个内部子请求给你指定的鉴权接口,接口返回200就放行,返回401或403就拒绝,然后把鉴权接口返回的一些头信息透传给上游业务服务。这一节我会给出可以照抄的配置。

3.1 先搭一个能签发Token的登录接口(FastAPI最小实现)

登录接口和鉴权接口本可以在同一个服务里,但建议拆成两个路径,/api/login只在登录时调用,/api/authz只给Nginx子请求调用,这样日志可以分别追踪。

# token_auth.py —— ROS Web认证的最小FastAPI实现 from fastapi import FastAPI, Header, HTTPException import jwt, time, os app = FastAPI() SECRET = os.environ.get("ROBOT_JWT_SECRET", "change-me-in-production") @app.post("/api/login") def login(payload: dict): # 机器人没有完整账号体系时,以操作员ID+口令代替 operator_id = payload.get("operator_id") secret = payload.get("secret") if secret != os.environ.get("ROBOT_OPERATOR_SECRET", "op-placeholder"): raise HTTPException(status_code=401, detail="bad secret") now = int(time.time()) token = jwt.encode( { "sub": operator_id, "iat": now, "exp": now + 7200, # 2小时过期,兼顾操作时长与安全 "scope": "cmd_vel:pub;map:sub" }, SECRET, algorithm="HS256" ) return {"token": token, "expires_in": 7200}

逻辑说明:登录接口校验操作员口令,通过后签发HS256 JWT。注意这里只用了最简的固定口令做演示,生产环境建议对接自己的账号服务或至少换成 bcrypt 密文比对。exp设7200秒是为了一次巡检任务足够用,但又不会让token长时间有效。

@app.post("/api/authz") def authorize(authorization: str = Header(None, alias="Authorization")): # Nginx的auth_request会转发原始Authorization头到这里 if not authorization or not authorization.startswith("Bearer "): raise HTTPException(status_code=401, detail="missing token") token = authorization.split(" ", 1)[1] try: payload = jwt.decode(token, SECRET, algorithms=["HS256"]) return {"x_auth_user": payload.get("sub", "")} except jwt.PyJWTError: raise HTTPException(status_code=401, detail="token invalid or expired")

逻辑说明:/api/authz是被Nginx内部调用的鉴权接口,成功时返回2xx即可,Nginx不关心响应体内容;失败时必须返回401。这里的x_auth_user会被Nginx捕获,之后可以透传给上游rosbridge或其他Web服务。algorithms参数要显式指定HS256,不要省略,否则存在算法混淆攻击的风险。

启动时用uvicorn token_auth:app --host 127.0.0.1 --port 8000跑起来,记住这个鉴权服务只要监听本机就行,不需要暴露到局域网。

3.2 Nginx auth_request完整配置:把ROS HTTP接口保护起来

当鉴权服务起来之后,Nginx配置可以直接套用下面这份。假设机器人内部HTTP服务跑在9090端口,可以是rosbridge,也可以是任何ROS Web界面:

# /etc/nginx/conf.d/robot_gateway.conf server { listen 443 ssl; ssl_certificate /etc/nginx/ssl/robot_fullchain.crt; ssl_certificate_key /etc/nginx/ssl/robot_key.key; # 内部鉴权入口,只有Nginx自己可以访问 location = /auth { internal; proxy_pass http://127.0.0.1:8000/api/authz; proxy_pass_request_body off; proxy_set_header Content-Length ""; proxy_set_header Authorization $http_authorization; proxy_set_header X-Original-URI $request_uri; } location / { auth_request /auth; auth_request_set $auth_user $upstream_http_x_auth_user; proxy_pass http://127.0.0.1:9090; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_set_header X-Auth-User $auth_user; } }

逻辑说明:location = /auth是内部子请求地址,返回200则进入后续转发。proxy_pass_request_body off;和Content-Length ""一起使用,避免把原始请求体再次传给鉴权服务,既能省流量,也避免因body过大导致鉴权接口超时。auth_request_set把鉴权接口返回的x_auth_user头放进变量,再以X-Auth-User头传给上游,这样rosbridge或Web服务能拿到身份信息。proxy_set_header Upgrade和Connection "upgrade"是WebSocket握手的关键,缺少这两行,浏览器连接会停在101切换协议阶段。

3.3 过期时间、密钥轮换与上游超时:这三组参数要一起调

第一组是过期时间。我建议短期token给30分钟到2小时,另配一个7天有效的refresh token专门用来换新token,前端在检测到401后自动走刷新流程。这样用户不用频繁重新登录,但泄露出短期token的破坏窗口被压到很小。refresh token存HttpOnly Cookie,走单独的刷新接口,不入localStorage。

第二组是密钥轮换。JWT标准支持kid头,最简单做法是在签发时把密钥版本写进载荷,鉴权时根据版本选不同密钥。小项目也可以不做版本管理,但至少要在配置里留一个切换开关,让机器人在不重启业务服务的情况下能换密钥。

第三组是鉴权上游超时。auth子请求一旦慢,整个页面就会卡住。给/auth所在的location单独设置短超时:

location = /auth { proxy_connect_timeout 1s; proxy_read_timeout 1s; proxy_send_timeout 1s; }

逻辑说明:三个超时分别限制连接鉴权服务、读取鉴权响应、发送鉴权请求的时间。1秒对本地FastAPI足够,响应超过1秒便视为异常,页面会快速失败而不是长时间白屏。如果线上偶尔出现502,优先查鉴权服务本身,而不是加超时时间。

注意:整个网关必须走HTTPS,否则token在局域网里被中间人截获,前面做的所有认证等于白搭。自签证书可以,但要把根证书装到每台访问终端的信任库里,不然浏览器会直接拦死。

4. WebSocket的认证:实时通道不能只做一次握手就算完

HTTP接口的认证相对直白,真正的坑集中在WebSocket这条路上。rosbridge通常是WebSocket长连接,浏览器管理机器人指令基本都走这条通道。如果WebSocket没认证好,前面Nginx挡得再严,控制指令还是可以从别的路径透进去。

4.1 浏览器不给自定义Header,token怎么进入WebSocket握手

WebSocket握手本质上是一次HTTP升级请求,但它和普通HTTP请求有一个明显区别:浏览器的new WebSocket(url)不允许设置自定义Header,Authorization头在构造函数里根本带不进去。这是大量项目翻车的起点——后端说“你带token啊”,前端说“我带不进去”。

常见的做法有两种:把token放在URL query参数里,或者放在Sec-WebSocket-Protocol子协议字段里。前一种实现简单,适合绝大多数场景;后一种符合“协议协商”的语义,但需要rosbridge和自定义子协议配合,复杂度更高。我最常用的是URL参数方案:

// ws_client.js —— 带token建立rosbridge连接 const token = localStorage.getItem("robot_token"); if (!token) { throw new Error("no token, please login first"); } // 路径里的token会在Nginx层被提取出来拼成Authorization头 const ws = new WebSocket( `wss://robot.local/ws?token=${encodeURIComponent(token)}`, ["rosbridge"] ); ws.onopen = () => { console.log("rosbridge connected"); };

逻辑说明:前端把token放到URL里,绕开浏览器无法自定义Header的限制。用encodeURIComponent包裹是为防止token里有特殊字符。["rosbridge"]是rosbridge要求的子协议,保持这个值不变。注意这个方案的弊端是token会出现在Nginx access log和浏览器历史里,所以token必须短时效,最好不超过30分钟。

4.2 在Nginx层把URL参数转成Authorization头(含map配置)

前端把token放在URL query后,Nginx侧需要用map指令把它转换成鉴权所用的Authorization头。这个map要放在nginx.conf的 http 块内、server块之外:

# 在nginx.conf的http块中定义,注意要在server块之前 map $arg_token $auth_header { default $http_authorization; "~^(.+)$" "Bearer $1"; }

逻辑说明:$arg_token是Nginx内置变量,自动读取URL中的token参数。当URL带token时,正则~^(.+)$命中,$auth_header就变成Bearer <token>;当URL没带token时,回落到$http_authorization,也就是普通HTTP请求自带的Authorization头。这样一个变量同时兼容WebSocket和普通HTTP。

然后在鉴权子请求里使用这个变量:

location = /auth { internal; proxy_pass http://127.0.0.1:8000/api/authz; proxy_set_header Authorization $auth_header; } # WebSocket专用路由,避免和普通POST请求混在一起 location /ws/ { auth_request /auth; auth_request_set $auth_user $upstream_http_x_auth_user; proxy_pass http://127.0.0.1:9090/; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_set_header X-Auth-User $auth_user; }

逻辑说明:/ws/路由单独处理WebSocket升级,proxy_pass末尾带/会把/ws/前缀剥掉,直接把请求转发给rosbridge的/路径,这样rosbridge不用感知外部路由结构。auth_request在握手阶段执行一次,鉴权通过后Nginx才把升级请求转发给上游。

4.3 长连接不能只靠一次握手:动态期限与心跳续期

网关层认证的局限在这里体现得很明显:WebSocket握手只发生一次,连接建立后Nginx不会再对每条消息做鉴权。于是出现一个很尴尬的场景——token已经过期,但连接还活着,操作员还在通过这条连接发控制指令,权限实际上悬空。

我习惯在这层加一个“心跳续期”机制。前端每隔一段时间把token发给机器人上的会话服务,服务端校验token的新鲜度,发现过期就主动断开连接:

# ws_guard.py —— 对已建立的连接做周期性重新鉴权 import asyncio async def keepalive_guard(ws, token, auth_service, interval=30): """每隔interval秒校验一次token,失败则断开WebSocket""" while not ws.closed: await asyncio.sleep(interval) ok = await auth_service.verify(token) if not ok: await ws.close(code=4401, reason="token expired") break

逻辑说明:interval=30表示每30秒校验一次,对机器人控制场景足够灵敏,又不会产生太大网络开销。4401在WebSocket规范里属于自定义关闭码,前端能据此弹出重新登录提示。注意不要在rosbridge的onmessage回调里做同步鉴权,那会阻塞消息队列,导致指令延迟重排。

提示:如果机器人对实时性要求极高,比如需要200Hz以上的控制频率,30秒的心跳校验也会挤占带宽。可以把校验频率降到60秒,并在网关层增加总连接时长上限,比如最长允许连接4小时,到期强制重连。

5. 避坑:ROS Web认证中的五个典型问题与排查思路

这一节记录的是我在实际部署中遇到的坑,每条都按“现象 → 原因 → 解决”的顺序写,排查时可以直接对照。

5.1 reload后WebSocket握手从“顺滑”变“翻车”

现象:Nginx配置改完执行nginx -s reload,HTTP接口还正常,但浏览器WebSocket一直握手失败,Nginx错误日志里出现upstream prematurely closed connection或502。

原因:/auth子请求默认会转发原始请求体,而WebSocket升级请求带有Upgrade头。鉴权服务拿到一个带Upgrade的POST请求后可能不知道如何响应,或者因为Content-Length不匹配直接断开。问题不在业务服务,而在auth子请求的转发参数。

解决:确认/auth里已经设置proxy_pass_request_body off;和proxy_set_header Content-Length "";。这两行必须同时存在,缺一个都可能让鉴权接口读到一个空壳请求体。

5.2 HTTP能过、WebSocket却401:Authorization头根本没带过去

现象:浏览器访问Nginx保护的HTTP接口一切正常,但同一个token建立WebSocket连接时始终报401,看Nginx access log发现请求根本没到鉴权服务。

原因:前端new WebSocket()不带Authorization头,而Nginxauth_request默认只转发Upgrade请求的原始header,token放在URL query里的话,原始header里根本没有Authorization,鉴权服务自然判定未登录。

解决:用上一章4.2节的map方案,把URL参数转成Authorization头。排查时可以先用命令行工具模拟一次带?token=的握手,确认Nginx是否产生了正确的子请求。

5.3 CORS预检被auth_request误杀

现象:前端页面和机器人不在同一个域,浏览器发出跨域请求时先发OPTIONS预检,Nginx把预检请求也丢给鉴权服务,鉴权服务不认识OPTIONS直接返回401,前端报CORS错误。

原因:Nginx的auth_request对每个请求都会执行,包括CORS预检。预检请求本身不带业务token,被鉴权服务拒绝之后,浏览器得不到正确的CORS响应头,整个请求被中断。

解决:在受保护的location里对OPTIONS直接返回204,不再触发auth_request:

location / { if ($request_method = OPTIONS) { add_header Access-Control-Allow-Origin "*"; add_header Access-Control-Allow-Methods "GET, POST, OPTIONS"; add_header Access-Control-Allow-Headers "Authorization, Content-Type"; return 204; } auth_request /auth; proxy_pass http://127.0.0.1:9090; }

逻辑说明:if在location里只做return,不会引入Nginx的if陷阱。提前放行OPTIONS是为了让浏览器能拿到允许跨域的响应头。Access-Control-Allow-Headers必须包含Authorization,否则浏览器会认为自定义Header不被允许。

5.4 token过期后连接还活着:长连接不能只认握手

现象:操作员在机器人旁边操作了很久,token已经过期,但网页上的控制按钮还能用,底盘还能响应指令。前端明明在很早之前就收到了401。

原因:握手鉴权只发生在连接建立瞬间,之后所有消息都直接透传给rosbridge,Nginx不再检查token。这个问题的危害比HTTP接口更大,因为WebSocket是长连接,控制通道可以一直开着。

解决:按4.3节的keepalive_guard方案增加周期性校验。如果不想动后端代码,也可以在Nginx层面用map加近似的请求限流,但最可靠的还是在业务层的消息代理里做校验。不要指望前端主动断开,权限控制永远要在服务端执行。

5.5 wss连不上、ws却没事:证书链不完整才是黑匣子

现象:机器人同时开HTTP和HTTPS,浏览器用ws://连接一切正常,换成wss://就报证书错误或连接失败,但Nginx证书配置明明看着没问题。

原因:wss://要求的是完整证书链。很多自签证书只生成了服务器证书,没有把中间CA证书合进去,浏览器不认识这个签发者,直接拒绝连接。ws://不校验证书,所以表现正常,给人造成“配置没毛病”的错觉。

解决:检查机器人上的证书文件是否包含完整链,最简单方式是数一下证书块数量:

grep -c "BEGIN CERTIFICATE" /etc/nginx/ssl/robot_fullchain.crt

逻辑说明:正常fullchain文件应包含2个以上certificate块,服务器证书一个,中间CA一个或两个。如果输出是1,说明只有服务器证书,需要把中间CA证书追加到同一个文件里。追加之后记得nginx -t && nginx -s reload。

6. 从“能进”到“能做什么”:ROS话题级授权的一种轻量实现

网关层解决的是“你是谁”,但真正决定一个操作员能不能按“启动”按钮的,是“你能做什么”。这个话题级授权,我习惯在rosbridge前面再放一个轻量消息代理,对JSON-RPC做逐条topic过滤,而不是把所有控制逻辑堆在Nginx里。

6.1 在JSON-RPC消息进ROS之前做话题过滤

rosbridge收到的消息长这样:{"op":"publish","topic":"/cmd_vel","msg":{...}}。基于Token里预先声明的scope,可以在消息进入ROS之前拒绝掉越权指令:

# topic_guard.py —— 在JSON-RPC消息进ROS前做话题级过滤 import json SCOPE_POLICY = { "cmd_vel": {"pub": True, "sub": False}, "map": {"pub": False, "sub": True}, "battery": {"pub": False, "sub": True}, } def guard_message(token_scope: dict, raw_msg: str): """解析rosbridge JSON-RPC,校验操作是否匹配scope,返回None表示拒绝""" msg = json.loads(raw_msg) op = msg.get("op") topic = msg.get("topic", "").lstrip("/") if op in ("publish", "subscribe"): allowed = token_scope.get(topic, {}) if not allowed.get("pub" if op == "publish" else "sub"): return None if op == "call_service": service = msg.get("service", "") if service not in token_scope.get("services", []): return None return raw_msg

逻辑说明:这个代理不对消息做解密或透传,只做一个动作:解析JSON,判断op与topic是否在scope中允许,不允许就丢弃。token_scope由前面的JWT载荷解析出来,可以在握手时初始化。这样即使某个操作员拿到了合法连接,也只能 publish 到被授权的topic,无法乱动别的节点。

6.2 把权限绑定到token而不是IP:事后才能定位到人

最后一点习惯:不要把权限绑定到IP。局域网里IP冲突、DHCP翻车、甚至隔壁工位临时借用一台电脑,都会让“根据IP判断身份”失效。把权限绑到token上,每个操作员拿到的token是独立的,一旦发现机器异常动作,直接查网关日志里的sub字段,比查IP可靠得多。

我后来把这一整套链路整理成固定的排查步骤:先查Nginx日志里有没有401,再查鉴权服务日志里有没有decode失败,最后查消息代理日志里有没有被topic_guard丢弃的记录。这半年在token过期上被反复折磨过好几次,最后发现最值得投入的还是把整个认证链路做成闭环——网关负责挡陌生人,消息代理负责管熟人,两者缺一不可。希望这一篇能帮到你少走几步弯路。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进