背景:IM 文件接入后,下载链路也要做权限控制
云盘合入大象后,群聊里的图片、附件和其他资源由云盘存储。底层文件放在内部基于 Swift 的对象存储中。对象路径可以定位文件,但不能直接暴露给客户端。对象路径本身不能证明请求方有访问权限;群成员变化、消息撤回或文件权限调整后,旧链接也可能继续被访问。
下载请求需要先确认三件事:请求方是否已登录,是否属于对应群聊或消息上下文,以及这条消息关联的对象路径是什么。这个判断依赖 Java 服务中的 IM、云盘元数据和权限逻辑。
最直接的实现是由 Java 接住下载请求,完成鉴权和寻址后,再从 Swift 拉取对象并把 InputStream 写到客户端响应。早先的 IM 消息文件实现 和 高性能 IO 已经分析过这条链路的问题:Java 被放进了文件数据路径,每个下载会占用客户端连接、Java 处理资源、上游 HTTP 连接和 JVM 缓冲。文件流量在内部多经过一跳,Java 侧还需要按文件传输量规划网络与内存资源。
这篇记录当时落地的文件网关:把 Java 留在权限判断和对象寻址的控制面,文件字节由 OpenResty 的 Nginx 代理链路直接从 Swift 回给客户端。
设计目标
这个网关将职责拆开:
- 权限服务负责结论。 Java 根据用户身份、IM 上下文和文件元数据返回允许或拒绝;允许时附带对象的 bucket、path、
Content-Type等代理所需信息。 - OpenResty 负责入口编排。 Lua 读取请求中的 Cookie、Header 和消息标识,调用权限服务,拒绝时立即返回
401,允许时把对象定位结果写入 Nginx 上下文。 - Nginx 负责文件转发。 内部跳转后,请求进入固定的代理 location,由
proxy_pass连接 Swift 并转发响应。Lua 和 Java 不读取或拼接文件响应体。
图中有两条路径。橙色虚线是控制面,传递身份、消息标识、权限结论和对象路径等元数据。蓝色实线是数据面,文件从 Swift 经 Nginx 返回客户端。鉴权明确拒绝的请求在入口结束,不访问对象存储。
请求如何经过网关
以群聊图片为例,请求 URL 只携带文件标识或消息标识,身份信息仍由 Cookie 或 Header 提供。网关不能相信客户端传来的 bucket 或对象路径,这些字段只能由权限服务返回。
- 客户端请求
/im-file/{messageId},携带登录态。 access_by_lua提取身份和messageId,调用 Java 的内部鉴权接口。- Java 校验用户与群、消息、文件的关系;不通过时返回拒绝,通过时返回对象路径和响应元信息。
- Lua 将路径写进 Nginx 变量,使用内部 rewrite 转到专门的对象代理 location。
- 代理 location 使用
proxy_pass请求 Swift。Nginx 从上游读取时持续向客户端写入,文件内容不进入 Lua 或 Java。
权限接口的返回内容只表达网关需要的最小信息。例如:
{
"allow": true,
"upstream": "swift-internal",
"objectPath": "/im-bucket/2016/12/abc123",
"contentType": "image/jpeg"
}接口不应把可从公网直接访问的长期有效对象 URL 返回给客户端。客户端访问网关 URL,对象路径只用于网关的内部代理请求。权限服务不返回文件内容,因此可以分别观察权限判断耗时和对象传输的带宽成本。
Lua 只做控制面调用
Lua 侧的逻辑应保持短小:组装鉴权请求、设置超时、解析小 JSON、根据结果进入内部 location 或结束请求。下面是简化后的结构,字段和内部接口名均作了泛化:
local http = require("resty.http")
local cjson = require("cjson.safe")
local client = http.new()
client:set_timeout(100) -- 控制面超时,不能跟着文件下载时间走
local res, err = client:request_uri("http://file-auth.internal/authorize", {
method = "POST",
body = cjson.encode({
messageId = ngx.var.message_id,
cookie = ngx.var.http_cookie,
authorization = ngx.var.http_authorization,
}),
headers = { ["Content-Type"] = "application/json" },
})
if not res then
return ngx.exit(ngx.HTTP_SERVICE_UNAVAILABLE)
end
local result = cjson.decode(res.body)
if res.status ~= ngx.HTTP_OK or not result or not result.allow then
return ngx.exit(ngx.HTTP_UNAUTHORIZED)
end
ngx.var.swift_upstream = result.upstream
ngx.var.swift_object_path = result.objectPath
ngx.header["Content-Type"] = result.contentType
return ngx.exec("/_internal/swift-object")这里有两个边界。
第一,Lua 不应该用 socket.http 把 Swift 文件读成字符串,再用 ngx.print 输出。这会让文件内容进入 Lua 用户态,重新引入缓冲、内存占用和错误处理问题。第二,鉴权服务不可用时不应放行。权限超时、上游错误或返回内容无法验证时应结束请求并返回 5xx;权限不确定时继续提供对象会形成越权路径。示意代码将所有非 200 或未允许结果归为 401,生产实现应区分明确拒绝与鉴权服务故障。
为什么用内部跳转分离鉴权和对象代理
如果让 Lua 按请求决定完整的代理目标,鉴权和对象代理会混在同一个 location,连接管理、错误处理和日志也难以统一。这里将流程拆成入口和内部代理两个 location:入口 location 只做鉴权,内部 location 只做对象代理。上游组 swift_cluster 保持固定,权限服务只提供该请求允许访问的对象路径。
location ~ ^/im-file/(?<message_id>[^/]+)$ {
access_by_lua_file conf/lua/im_file_auth.lua;
}
location = /_internal/swift-object {
internal;
proxy_set_header Host $swift_upstream;
proxy_pass http://swift_cluster$swift_object_path;
}示意配置省略了鉴权接口地址、TLS、上游健康检查和真实的 URI 规则。internal 会拒绝客户端对 /_internal/swift-object 的直接请求,内部跳转仍可进入这个 location,ngx.exec 只是其中一种方式。代理层使用的对象路径来自权限服务,客户端参数不会直接拼到上游 URL 上。swift_upstream 仅用于设置 Host 请求头,不应由客户端控制,并应限制为 Swift 服务接受的值。
实际配置还要明确处理请求方法、Range、上游超时和响应头。图片预览和大文件下载都可能使用 Range 请求,应确认 Range 请求头以及 Swift 返回的 Accept-Ranges、Content-Range、Content-Length 和状态码能按预期传递。Nginx 默认会转发多数客户端请求头,网关仍应显式审查哪些请求头可以到达 Swift,避免把 Cookie、内部认证头等无关头部带到数据面。proxy_read_timeout 约束的是上游长时间无数据的等待,不应按鉴权接口的几十或几百毫秒超时机械设置。
性能账:移除 Java 的数据面中转
Java 中转时,文件路径大致是:
客户端 → 文件网关 → Java 服务 → Swift → Java 服务 → 文件网关 → 客户端Java 同时承担权限判断和文件字节转发。下载并发增加时,Java 的线程、连接池、堆缓冲和 GC 都需要纳入容量计算;上游读取慢或客户端接收慢时,这些资源的占用时间会增加。
改造后,控制面和数据面分开:
控制面:OpenResty → Java 鉴权服务 → OpenResty
数据面:客户端 → OpenResty/Nginx → Swift → OpenResty/Nginx → 客户端Java 的 QPS 仍然随下载请求数增长,但传输的是小 JSON,不再随文件大小增长。对象存储带宽仍要按真实下载量规划,OpenResty 仍需要连接、网络缓冲和上游保护;减少的是 Java 中转这一层的带宽、内存和失败点,而不是让文件下载没有成本。
这也延续了第 9 篇的结论:在文件转发场景里,减少 Java 中转节点通常比继续优化 InputStream、NIO 或零拷贝实现更直接。Nginx 的代理模块适合处理上游到下游的转发;Lua 保持在请求决策路径,避免处理文件响应体。
安全与可用性上的约束
网关上线后,需要持续检查以下约束是否成立:
- 对象路径只由服务端生成。 路径、bucket、
Content-Type等由 Java 根据服务端元数据生成,网关不把请求参数直接作为 Swift URI。服务端还应校验路径属于允许的对象命名空间,并避免路径规范化或编码差异改变实际访问的对象。 - 鉴权失败时不访问 Swift。 鉴权服务超时、返回格式异常或明确拒绝时,网关不访问 Swift。身份无效可返回
401;已认证但无权访问时,如客户端协议需要区分,可返回403。服务不可用和无法验证的鉴权结果应返回5xx。 - 内部 location 不对外开放。 对象代理 location 使用
internal,并在网关层保留统一的审计日志,记录请求方、文件标识、鉴权结果、上游状态和耗时,不记录完整 Cookie。 - 为控制面和数据面分别设置保护措施。 鉴权接口需要独立的连接池、超时和限流,防止控制面异常占满网关资源。Swift 上游也需要独立的连接与超时配置,防止慢对象读取耗尽连接。
- 默认不缓存权限结果。 群成员、消息撤回和文件删除都可能改变结论。若以后为热点图片缓存允许结果,缓存键必须包含用户或可靠的权限版本,并通过撤回、退群等事件主动失效;没有这套失效机制时,每次请求实时校验更稳妥。若缓存对象响应,鉴权必须发生在缓存命中判断之前,缓存键和响应头也不能让一个用户的授权结果被另一个用户复用。
当时的边界
这套方案解决的是下载数据面不经 Java 中转,不能替代对象存储的访问控制。Swift 在网络层仍应只接受网关或受控上游访问。对象路径泄露后,外部请求仍应无法绕开网关读取对象。
此外,OpenResty 不是业务规则的归宿。群成员关系、消息归属、撤回状态和云盘权限继续由 Java 服务维护。Lua 脚本只把请求上下文交给该服务,并根据确定的结果调度 Nginx。这样规则变更仍集中在业务服务,网关只需要稳定地执行“校验后代理”这一件事。
这次改造把文件权限管理保留在服务端,同时避免了 Java 承担大文件中转。后续压测时,控制面应看鉴权接口 QPS、RT 和拒绝率,数据面应看网关连接数、上游 RT、带宽、4xx/5xx 与客户端中断率。分开记录两套指标后,可以区分鉴权故障与对象传输故障。

