目 录CONTENT

文章目录

Nginx auth_request 文件鉴权实战:从连环 500 到多租户共存

七月流火
2026-08-12 / 0 评论 / 0 点赞 / 20 阅读 / 0 字

记录一次给内网对象存储(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_token cookie(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 步

  1. Nginxmap $request_uri $tenant_arg 里确认租户名;复制一个 location = /__file_check_xxx(填该租户校验服务 IP + 密钥);在 /__file_check 里加一行 if 分流;
  2. 前端:文件 URL 拼上 &tenant=xxx(各工程统一拼接处一行);
  3. 后端:该租户的校验服务部署 /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。

七、总结

这套方案的三个关键认知:

  1. auth_request 子请求是"无 query、有 header/cookie"的——token 要么走 cookie,要么由 Nginx 用 map $request_uri 提取后转 header;
  2. 子请求里的 proxy_pass 别玩花活——固定 URI 直连(或正确配置的 upstream)是唯一稳的写法;
  3. auth_request 不支持变量 URI——多租户分流用"固定入口 + 端点内 rewrite"实现。
0

评论区