记录一次给内网对象存储(MinIO)接入"鉴权后访问"的完整过程:auth_request 子请求校验、token 透传、多租户分流,以及一路上踩过的 7 个坑。
一、需求背景
某项目的文件(图片、视频)存储在内网 MinIO,通过 Nginx 反向代理对外提供访问。原始方案是"公开读",任何人都能直接拿 URL 访问文件,存在越权泄露风险。需求是:
- 没有 token / token 无效 → 401 拒绝
- token 有效 → 正常出图/出视频
- 同一套文件域名要被多个项目(多租户)共用,各租户有独立的用户体系和独立的校验服务
第一版设计:利用 Nginx 的 auth_request 模块,在反代 MinIO 之前先发一个内部子请求到后端的校验端点,根据子请求返回的 200/401 决定放行还是拒绝。
二、总体架构
浏览器/小程序
| 请求 https://files.example.com/assets/2026/08/12/xxx.jpg?tenant=prod&file_token=xxx
v
Nginx (location /)
| auth_request /__file_check <- 固定 URI,发内部子请求
v
校验端点(内部) -- 按 tenant 分流 --+--> 租户A 校验服务 (独立IP/密钥)
+--> 租户B 校验服务
+--> 默认租户校验服务
| 200 放行 / 401 拒绝
v
反代 MinIO 出图
校验端点逻辑(后端,以 Spring Boot 为例):
@GetMapping("/fileCheck")
public ResponseEntity<Void> fileCheck(
@RequestHeader(value = "X-Auth-Check-Key", required = false) String checkKey, // 共享密钥
@RequestHeader(value = "X-File-Token", required = false) String headerToken, // 凭证①
@RequestParam(value = "file_token", required = false) String queryToken, // 凭证②
HttpServletRequest request)
{
// 1. 共享密钥校验(防止端点被绕过/滥用)
if (StringUtils.isEmpty(checkSecret) || !checkSecret.equals(checkKey)) return 401;
// 2. 来源 IP 白名单(可选)
// 3. 限流(可选)
// 4. token 校验:header > query > cookie 三级凭证链
// JWT 解析 + Redis 登录态校验,通过返回 200,否则 401
}
三、最终 Nginx 配置(可直接套用)
http 级:三个 map + 脱敏日志格式
# ① access log 脱敏:把 query 里的 file_token 抠掉再记日志,防止 token 落盘
map $args $sanitized_args {
default $args;
"~^(.*)&file_token=[^&]*(&.*)$" $1$2;
"~^(.*)&file_token=[^&]*$" $1;
"~^file_token=[^&]*(&.*)$" $1;
"~^file_token=[^&]*$" "";
}
# ② 提取 token → 转成 header 传给校验端点(关键,见踩坑 4)
map $request_uri $file_token_hdr {
default "";
~[?&]file_token=([^&]+) $1;
}
# ③ 提取租户标识 → 分流到不同校验端点(见多租户章节)
map $request_uri $tenant_arg {
default "default";
~[?&]tenant=([^&]+) $1;
}
# ④ 脱敏日志格式(配合 access_log 使用)
log_format file_main escape=json '$remote_addr - $remote_user [$time_local] '
'"$request_method $uri$is_args$sanitized_args $server_protocol" '
'$status $body_bytes_sent "$http_referer" "$http_user_agent"';
server 级:校验总入口 + 文件下载入口
server {
listen 443 ssl;
server_name files.example.com;
log_subrequest on; # 调试期建议开启:子请求也会写 access log,否则看不到校验子请求
# ---- 校验总入口:auth_request 必须用固定 URI(踩坑 7)----
location = /__file_check {
internal; # 外部无法直接访问
access_log /var/log/nginx/check.log; # 子请求日志,方便排查
# 多租户分流:按 tenant 跳到对应租户端点(rewrite 目标是固定字符串)
if ($tenant_arg = "prod") { rewrite ^ /__file_check_prod; }
if ($tenant_arg = "other") { rewrite ^ /__file_check_other; }
# 默认租户:直接转发
proxy_pass http://10.0.0.12:9217/fileCheck; # 注意:固定 URI,无变量(踩坑 1)
proxy_pass_request_body off;
proxy_set_header Content-Length "";
proxy_set_header X-Auth-Check-Key "<共享密钥>";
proxy_set_header X-File-Token $file_token_hdr; # token 经 header 传递(踩坑 4 的解法)
proxy_connect_timeout 3s;
proxy_read_timeout 5s;
}
# ---- 各租户独立校验端点(新增租户 = 复制一段,改 IP 和密钥)----
location = /__file_check_prod {
internal;
access_log /var/log/nginx/check.log;
proxy_pass http://10.0.0.99:9217/fileCheck;
proxy_pass_request_body off;
proxy_set_header Content-Length "";
proxy_set_header X-Auth-Check-Key "<生产共享密钥>";
proxy_set_header X-File-Token $file_token_hdr;
proxy_connect_timeout 3s;
proxy_read_timeout 5s;
}
# ---- 文件下载入口:先鉴权,再反代 MinIO ----
location / {
auth_request /__file_check; # 固定 URI(踩坑 7)
error_page 401 = @file_denied;
error_page 403 = @file_denied;
proxy_pass http://10.0.0.59:8080; # MinIO
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header Connection "";
# 视频/大文件断点续传(Range 原生透传,必需)
proxy_set_header Range $http_range;
proxy_set_header If-Range $http_if_range;
proxy_buffering off; # 视频流式播放
proxy_read_timeout 300s;
}
# 未登录/token 过期:401,前端据此跳登录
location @file_denied {
add_header WWW-Authenticate 'Bearer realm="files.example.com"' always;
return 401;
}
}
前端接入
- Web 端:登录成功后种一个
file_tokencookie(domain设为父域,让文件子域名能带上),<img>/<video>自动携带; - 小程序/App 端:无 Cookie 机制,在文件 URL 上拼
?file_token=xxx&tenant=xxx(token 用encodeURIComponent编码,JWT 本身是 base64url 字符,编码后不变)。
四、踩坑实录(核心)
踩坑 1:proxy_pass 的 URI 带变量 → 全家 500
第一版校验端点写的是:
proxy_pass http://file_auth_pool/fileCheck$is_args$args;
结果:带 token、不带 token、任何请求全部 500(170 字节的内置错误页)。Nginx 对 auth_request 子请求里的"运行时拼接 URI"支持很差,在子请求上下文里,proxy_pass 的 URI 部分不要用变量,老老实实写固定字符串。
踩坑 2:upstream + 固定 URI → 400(其实是指向旧 IP)
把变量去掉,换成 upstream:
proxy_pass http://file_auth_pool/fileCheck;
结果变成 400,而且 error.log 干干净净。排查了很久才发现:upstream file_auth_pool 里写的还是已废弃的旧内网 IP(网络不通,但恰好连到了同网段另一个服务,返回 400)。400 不是 Nginx 生成的,是上游返回的。 把 upstream 改成正确 IP 后一切正常。这也是"多环境复用"的最好注解:upstream 负责抽象环境差异,location 永远不写死 IP。
踩坑 3:rewrite + 不带 URI 的 proxy_pass → 也 500
试过 rewrite ^ /fileCheck break; proxy_pass http://file_auth_pool;,同样 500。结论同上:这个 Nginx 版本下,子请求的 proxy_pass 必须"静态直连 IP + 固定 URI",任何间接解析都会出问题。
踩坑 4(最隐蔽):auth_request 子请求不带主请求的 query!
这是整个排障里最坑的一个。配置看起来都对,校验子请求也正常到达后端,但带 token 的请求依然是 401。百思不得其解,直到开了子请求日志:
log_subrequest on;
发现子请求日志里显示的请求行是主请求的完整 URL(含 query),让人误以为 query 透传了。但事实是:auth_request 创建子请求时根本不携带主请求的 query string,子请求里 $args 是空的,日志里看到的 query 只是 $request_uri 变量继承的假象!
解法:既然 $request_uri 在子请求里继承主请求的完整 URL,就用 map 从 $request_uri 里把 file_token 抠出来,转成 header 传给后端:
map $request_uri $file_token_hdr {
default "";
~[?&]file_token=([^&]+) $1;
}
...
proxy_set_header X-File-Token $file_token_hdr;
后端凭证链第一优先级就是 X-File-Token header,一举解决。
踩坑 5:X-Real-IP 透传可能误伤 IP 白名单
后端校验端点如果配了来源 IP 白名单,而 Nginx 子请求又透传了 X-Real-IP / X-Forwarded-For,后端拿到的会是公网客户端 IP,直接被白名单拒绝(401 且无日志)。要么白名单加上 Nginx 出口 IP,要么校验端点别透传这两个 header,让后端看到的就是 Nginx 内网地址。
踩坑 6:全部突然 401?先查 Redis 登录态
排障中段,同样的命令从 200 变成 401,一度以为配置又坏了。实际是测试账号被重新登录,单点登录类框架会踢掉旧会话的 Redis key,旧 token 全部失效。排查方法:后端 401 若无日志(密钥匹配、token 非空、解析成功),基本就是 Redis 里 login_tokens:{userKey} 不存在了,redis-cli EXISTS 一验便知。
踩坑 7:auth_request 用变量 URI → 500(不支持变量)
为了做多租户分流,第一版写成:
map $arg_tenant $file_check_uri { ... }
auth_request $file_check_uri; # ← 直接 500
这个 Nginx 版本 auth_request 不支持变量 URI,$ 解析不到就 500。必须固定 URI。那多租户怎么分流?把分流下沉到校验端点内部:
location = /__file_check {
internal;
if ($tenant_arg = "prod") { rewrite ^ /__file_check_prod; }
if ($tenant_arg = "other") { rewrite ^ /__file_check_other; }
proxy_pass http://10.0.0.12:9217/fileCheck; # 默认租户
}
auth_request 永远指向固定的 /__file_check,端点内部再按租户跳到各自的固定端点。tenant 同样用 map $request_uri 提取,rewrite 目标是固定字符串,全程不碰"变量拼接"。
五、多租户共存:新增一个租户只需 3 步
- Nginx:
map $request_uri $tenant_arg里确认租户名;复制一个location = /__file_check_xxx(填该租户校验服务 IP + 密钥);在/__file_check里加一行if分流; - 前端:文件 URL 拼上
&tenant=xxx(各工程统一拼接处一行); - 后端:该租户的校验服务部署
/fileCheck端点 + 配好共享密钥。
六、安全与运维要点
- 共享密钥:
X-Auth-Check-Key走配置中心管理,不进 Git,不硬编码; - 日志脱敏:access log 用
$sanitized_args格式(escape=json防注入),子请求日志(check.log)不记 token; - internal 端点:
location = /__file_check必须internal,否则会被外部直接打穿; - 排障技巧:
log_subrequest on看子请求;排障期先注释error_page 500/40x,否则真实状态码会被自定义错误页掩盖; - 验收口径:有效 token → 200 出图;无 token / 无效 token → 401。
七、总结
这套方案的三个关键认知:
- auth_request 子请求是"无 query、有 header/cookie"的——token 要么走 cookie,要么由 Nginx 用
map $request_uri提取后转 header; - 子请求里的 proxy_pass 别玩花活——固定 URI 直连(或正确配置的 upstream)是唯一稳的写法;
- auth_request 不支持变量 URI——多租户分流用"固定入口 + 端点内 rewrite"实现。
评论区