用 nginx 对文件服务器鉴权

标签:nginx

最近项目中遇到一个需求:
用户在使用 seafile 上传文件,希望将其分享出去。
seafile 自己生成的分享链接类似于 https://seafile.xxx.com/f/23cc6812408e44f8b63e/,若未过期且密码正确时会将其重定向到类似 http://seafile.yizhisec.com/seafhttp/files/e1687864-8a12-46db-9708-b4240df346be/1.txt 的地址。
产品希望能对这个分享链接进行鉴权,并且最好不暴露实际的文件地址和避免发起重定向的请求。

因为对 seafile 进行二次开发比较麻烦,最简单的对其鉴权的方式是使用 nginx 的 ngx_http_auth_request_module

server {
    # 省略无关配置

    location = /auth {
        internal;
        proxy_pass http://api-server/api/auth;
        proxy_http_version 1.1;
        proxy_pass_request_body off;
        proxy_set_header Content-Length "";
        proxy_set_header X-Original-URI $request_uri;
    }

    location /f/ {
        auth_request /auth;
        proxy_pass http://seafile;
        proxy_http_version 1.1;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    }
}
这段配置的作用是在访问 /f/ 前缀的链接时,nginx 会去调用 http://api-server/api/auth 进行鉴权。这个鉴权请求会被去掉 body,header 的 X-Original-URI 字段会被设置为原请求的 URI 的 host 之后的部分(即 path?query),其他 header 字段会被设为和原请求相同。
我们定制的鉴权代码可以检查 header 部分,并决定是否允许访问:如果返回 2xx 状态码,则 nginx 会继续请求并返回 seafile 服务;如果返回 401、403 和 500 状态码,则禁止访问并返回对应状态码;其他状态码会导致 nginx 报错,禁止访问并返回 500 状态码。当禁止访问时,不会输出鉴权响应的 body。

那么,如果客户端需要区分不同情况下的禁止访问需要如何解决呢?
我想当然地尝试用 if 来判断:
server {
    location /f/ {
        auth_request /auth;
        auth_request_set $code $upstream_http_x_code;
        proxy_pass http://seafile;
        proxy_http_version 1.1;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        if ($code) {
            return 403 '{"code": $code}';
        }
        if ($code = "") {
            proxy_pass http://api;
        }
    }
}
按我的想法,只要我在鉴权接口返回 403 时,同时设置一个 x-code 的 header,nginx 就会在 body 中返回这个 code。然而这却是不可行的,因为 ifngx_http_rewrite_module 模块的指令,它的执行流程早于 auth_requestauth_request_set,因此进行判断时,$code 永远是空字符串。

而要在请求之后再判断的话,就需要用到 error_page 指令了:
server {
    location /f/ {
        auth_request /auth;
        auth_request_set $code $upstream_http_x_code;
        proxy_pass http://seafile;
        proxy_http_version 1.1;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        error_page 403 = @handle_denied;
    }

    location @handle_denied {
        return 403 '{"code": $code}';
    }
}

接下来解决重定向的问题。
正常情况下,重定向后的地址仍然需要鉴权才能保证安全性。而重定向很可能是客户端所用的 HTTP 库自动处理的,添加 header 可能导致客户端改动较大。再加上重定向后的地址暴露了文件路径,所以最好还是后端来解决。
其实只要检查到重定向之后,让 nginx 再发起一次 proxy_pass 就可以了。但是默认情况下,nginx 会直接将 3xx 的状态码返回给客户端,只有当 proxy_intercept_errors 指令开启后时,才会执行 error_page 指令。
server {
    location /f/ {
        auth_request /auth;
        proxy_pass http://seafile;
        proxy_http_version 1.1;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_intercept_errors on;
        error_page 301 302 307 = @handle_redirects;
    }

    location @handle_redirects {
        set $redirect_to '$upstream_http_location';  # 必须再存个临时变量,否则下面用不了
        proxy_pass $redirect_to;
    }
}
考虑到 nginx 本来也需要对 seafile 文件的下载进行反向代理,因此直接在重定向时进行反向代理也并不会增加多少开销。
但是这样的配置对 seafile 返回的 Location 字段是有要求的:它的 host 部分需要能被 nginx 解析。正常情况下配置一个 dns 就能解决,而不巧的是我们的 nginx 是只能访问容器的内网环境的,而 seafile 返回的是一个外网地址。
无奈之下只好自行解析 Location 字段了:
map $upstream_http_location $location {
    ~^https?://[^/]+(.*)$    $1;  # 以 http 开头的截取 path 部分
    default                  $upstream_http_location;  # 不以 http 开头的则获取全部
}

server {
    location @handle_redirects {
        set $redirect_to '$location';
        proxy_pass http://seafile$redirect_to;
    }
}
这里的 map 块是和 server 块同级的,需要在 http 块内。它的作用是解析 $upstream_http_location 变量,当它符合 ~^https?://[^/]+(.*)$ 这个正则表达式时,将主机之后的部分截取为 $location 变量;当它只包含 path 和 query 时,直接全部保存为 $location 变量。
之后在 proxy_pass 指令中补上内网的主机地址,就可以正常访问了。

2023 年 11 月 28 日更新:
seafile 在文件分享后又被删除的情况下,会返回 200 状态码,并输出一个文件不存在的页面。我发现它的源码中并没有正确地设置状态码,于是提了个 pull request
不过等它合并不知道要多久,于是先自行解决吧。比较了一下和正常的文件下载不同的地方,发现正常下载会输出 Content-Disposition 头字段,用以描述文件名。因此只要发现状态码是 200,但是没有输出 Content-Disposition 时,把状态码改成 404 或 410 即可。
但是 nginx 并没有提供根据头字段修改状态码的指令,不得已只好用 OpenResty 来实现了,好在基本上是兼容的:
server {
    location /f/ {
        auth_request /auth;
        proxy_pass http://seafile;
        proxy_http_version 1.1;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_intercept_errors on;
        error_page 301 302 307 = @handle_redirects;
        header_filter_by_lua_block {
            if ngx.status == 200 and ngx.header["Content-Disposition"] == nil then
                ngx.exit(404)
            end
        }
    }
}

0条评论 你不来一发么↓

    想说点什么呢?