给私有目录加密码,很多人第一反应是 auth_basic。但 auth_basic 有个绕不过去的先天缺陷:它是 Nginx 自己拿着 htpasswd 文件做校验的,也就是说,你要给一百个用户开权限,就得维护一百行密码文件;你想做「登录后才能下载」、想接公司统一的账号体系、想按用户角色决定能不能访问某个文件,用 auth_basic 都会迅速变成一场运维噩梦。
真正的解法是把鉴权这个动作「外包」出去:Nginx 不自己判断,而是发起一个内部的子请求(subrequest)去问一个后端接口「这个请求该不该放行」,由你的 PHP 程序来回答。这就是 ngx_http_auth_request_module,也就是 auth_request 指令。本文把它的工作原理、完整配置、和 PHP 对接的代码、以及四类最容易踩的坑一次讲透。
一、auth_request 的工作机制:一次请求,两次往返
先理解它到底做了什么,否则后面的坑全都看不懂。
当你写下 auth_request /auth; 时,Nginx 处理一个原始请求的流程变成:
- 收到客户端请求
GET /private/report.pdf。 - Nginx 先构造一个内部子请求
GET /auth,把原始请求的头部(包括Cookie、Authorization、X-Forwarded-For等)按需带过去。 /auth由你的 PHP 后端处理,返回状态码:2xx 表示放行,401 或 403 表示拒绝,其他状态码视为错误。- 只有子请求返回 2xx,Nginx 才继续处理原始请求,把真正的文件或内容返回给客户端。否则直接把
error_page 401配置的响应返回,或者原样透传 401。
关键点在于:子请求是「内部」的。客户端完全不知道存在这么一次往返,浏览器只会看到一个最终结果。而且原始请求的响应体在鉴权通过之前根本不会被读取,所以对静态大文件来说,鉴权失败时几乎不产生磁盘 I/O——这一点是 auth_basic 做不到的优势。
二、最小可用配置:把 /private 目录护起来
假设你的站点根目录是 /www/zz1984,要保护的目录是 /www/zz1984/private,鉴权脚本放在站点的 /auth.php。
# 定义一个内部 location,只允许 Nginx 内部调用
location = /auth {
internal; # 关键:禁止客户端直接访问 /auth
proxy_pass http://127.0.0.1:9000/auth.php;
proxy_pass_request_body off; # 鉴权不需要请求体,省一次传输
proxy_set_header Content-Length "";
proxy_set_header X-Original-URI $request_uri;
proxy_set_header X-Original-Method $request_method;
}
location /private/ {
auth_request /auth;
# 鉴权失败时的兜底响应
error_page 401 = @login_redirect;
root /www/zz1984;
}
location @login_redirect {
return 302 /login.php?back=$request_uri;
}三个细节值得注意:
第一,location = /auth 必须加 internal;。不加的话,任何人都能直接请求 /auth 试探你的鉴权逻辑,轻则探测出规则,重则因为脚本逻辑不严谨被绕过。
第二,proxy_pass_request_body off 配合 proxy_set_header Content-Length "" 是一对,目的是让子请求不带请求体。普通 GET 请求本来就没有 body,但用户如果走的是 POST(比如表单下载),不带这个配置会把整个 POST 体转发两次,大文件上传时会明显浪费带宽。
第三,X-Original-URI 是给 PHP 用的。PHP 处理的是 /auth.php 这个子请求,$_SERVER['REQUEST_URI'] 拿到的是 /auth.php 而不是用户真正想访问的路径,必须靠自定义头把原始路径传进去。
三、PHP 侧怎么写:一个可落地的鉴权端点
Nginx 只认状态码,所以 PHP 的职责非常清晰:返回 200 就是放行,返回 401/403 就是拒绝,不要返回 500 或 302,那会让 Nginx 判成内部错误。
<?php
// auth.php —— 仅供 Nginx auth_request 内部调用
$uri = $_SERVER['HTTP_X_ORIGINAL_URI'] ?? '';
$method = $_SERVER['HTTP_X_ORIGINAL_METHOD'] ?? 'GET';
// 1. 取会话。Cookie 已经由 Nginx 原样带过来了
session_start();
$uid = $_SESSION['uid'] ?? null;
if (!$uid) {
http_response_code(401);
exit;
}
// 2. 路径白名单校验:防止用 ../ 穿越到别的目录
$real = realpath('/www/zz1984' . parse_url($uri, PHP_URL_PATH));
if ($real === false || strpos($real, '/www/zz1984/private/') !== 0) {
http_response_code(403);
exit;
}
// 3. 权限判定:这里可以查库、查 Redis、查统一账号系统
$pdo = new PDO('mysql:host=127.0.0.1;dbname=zz1984;charset=utf8mb4', 'authuser', '密码');
$st = $pdo->prepare('SELECT 1 FROM user_file_perm WHERE uid=? AND path_prefix=? LIMIT 1');
$st->execute([$uid, dirname($uri) . '/']);
if (!$st->fetchColumn()) {
http_response_code(403);
exit;
}
// 4. 把身份透传给后面的 location(可选)
header('X-Auth-Uid: ' . $uid);
http_response_code(200);第 2 步的 realpath 校验不能省。很多人写鉴权只看会话,结果用户虽然通过了登录,却能用 /private/../../etc/passwd 之类构造把请求指到别处——鉴权通过不等于路径合法,这是两个独立的判断。
第 4 步的 X-Auth-Uid 头如果要真正透传到后端业务,还需要在业务的 location 里用 auth_request_set 把它接住,见下一节。
四、auth_request_set:把子请求的返回信息带回主请求
子请求的响应头默认会被丢弃。如果你希望业务侧知道「是谁在访问」,必须用 auth_request_set 显式取出来,再注入到主请求的变量里。
location /private/ {
auth_request /auth;
auth_request_set $auth_uid $upstream_http_x_auth_uid;
auth_request_set $auth_status $upstream_status;
add_header X-Debug-Uid $auth_uid always;
# 给后端 PHP 带上用户身份
fastcgi_param AUTH_UID $auth_uid;
fastcgi_param AUTH_STATUS $auth_status;
}两个实战要点:
其一,$upstream_http_* 变量在子请求结束后会被清空,所以必须立即用 auth_request_set 存进自定义变量,否则后续日志或 header 里拿到的是空值。日志里想记录鉴权结果,就是靠这个把 $auth_status 写进 log_format。
其二,always 参数在 add_header 里很重要,它保证即使是 4xx/5xx 响应也会带上这个头,方便你在调试时确认子请求到底返回了什么。
五、四个静默失效点,每一个都会让你白忙一场
坑一:auth_request 只在 location 里生效,写在 server 块无效
auth_request 的上下文是 http、server、location,表面上看写在 server 块也行。但它继承到子 location 的行为受 location 匹配影响:一旦某个子 location 里出现了任何 auth_request 指令,继承就中断。更常见的问题是把它写在 server 块里,结果 location /private/ 里的正则 location 优先级更高,绕过了鉴权。稳妥做法是在真正需要保护的 location 上显式写,不要依赖继承。
坑二:子请求走的是 proxy_pass,但 Cookie 被默认丢弃
如果你用 proxy_pass 转发给 PHP-FPM 之外的容器,Nginx 默认会过滤掉带下划线的头,也会在某些配置下重写 Cookie。表现是:浏览器明明带着 PHPSESSID,PHP 侧 session_start() 后却是空的。解决办法有两个——要么在 http 块加 underscores_in_headers on;,要么把应用 cookie 名改成无下划线形式;另外确认转发时显式写了 proxy_set_header Cookie $http_cookie;。
坑三:error_page 401 不生效,因为子请求的状态码被吞了
直接写 error_page 401 /login.php 有时无效,原因是 auth_request 子请求返回 401 时,Nginx 把它当成「鉴权失败」,会走内部逻辑而不一定触发主请求的 error_page。可靠写法是使用命名 location:error_page 401 = @login_redirect;,并在 @login_redirect 里用 return 302 显式跳转。加等号 = 表示强制覆盖原状态码,这一点不加经常导致重定向后状态还是 401,SEO 抓取时会被判成错误页。
坑四:缓存把鉴权结果一起缓存了
如果你在保护目录上开了 proxy_cache 或 fastcgi_cache,默认的缓存键不含用户身份,结果是 A 用户通过鉴权后,B 用户直接命中缓存拿到内容,鉴权形同虚设。必须把身份变量塞进缓存键:
fastcgi_cache_key "$scheme$request_method$host$request_uri$auth_uid";
fastcgi_cache_bypass $auth_uid;
fastcgi_no_cache $auth_uid;或者干脆对受保护目录关闭缓存,用 fastcgi_cache off;。对于私密内容,「性能」永远排在「正确性」后面。
六、验证你的配置:三步确认真的生效了
改完配置,按顺序验证:
# 1. 未登录应被拒(期望 401 或 302)
curl -s -o /dev/null -w '%{http_code}\n' https://www.zz1984.com/private/test.pdf
# 2. 带合法会话应放行(期望 200)
curl -s -o /dev/null -w '%{http_code}\n' \
-H 'Cookie: PHPSESSID=xxxxxxxx' \
https://www.zz1984.com/private/test.pdf
# 3. 确认 /auth 不能从外部直接访问(期望 404)
curl -s -o /dev/null -w '%{http_code}\n' https://www.zz1984.com/auth
# 4. 看日志里鉴权子请求的状态码分布
grep 'auth' /var/log/nginx/auth.log | awk '{print $9}' | sort | uniq -c第 3 条尤其重要。如果 /auth 返回的不是 404 而是 200 或 500,说明 internal; 没生效或者你把它写在了错的 location 里,等于把鉴权逻辑直接暴露在公网上。
七、什么时候该用 auth_request,什么时候不该
它很适合:需要对接自有账号系统的私有目录、动态权限(按用户/角色/时间授权)、需要统一登录状态的下载站、以及想用后端逻辑决定是否返回 301 到登录页的场景。
它不适合:简单的全站一把锁(那用 auth_basic 更省事)、纯 CDN 场景(子请求会增加一次回源延迟,鉴权接口必须做到毫秒级,否则每个请求都多一次 RTT)、以及没有后端程序的纯静态站。
最后记住一个性能铁律:auth_request 相当于给每个受保护请求都加了一次额外的后端往返。所以鉴权端点里绝对不要做重活——不要查大表、不要调外部 API、不要读大文件。会话只查 Redis,权限只查一条带索引的记录,必要时把权限结果缓存 60 秒。鉴权本身成了瓶颈,比没有鉴权更糟糕。
八、和 auth_basic 的分工:什么时候两个一起用
实战里最稳的方案往往不是二选一,而是分层叠加。一个常见的组合是:外层用 auth_basic 挡住后台目录,内层用 auth_request 做细粒度权限。这样做的好处是,即使后端鉴权服务挂了,后台入口依然有一道静态口令挡着,不会因为「鉴权服务不可用」而完全敞开。
location /admin/ {
auth_basic "Restricted Area";
auth_basic_user_file /etc/nginx/.htpasswd_admin;
# 两道校验都在,先 basic 再 auth_request
auth_request /auth;
auth_request_set $auth_uid $upstream_http_x_auth_uid;
}
反过来,如果只用 auth_basic,你会遇到三个硬伤:用户管理要靠维护 htpasswd 文件,密码修改需要手工同步;没有登录年龄限制,无法做「会话过期」;无法按用户区分权限,所有人要么全能看、要么全不能看。auth_request 恰好补上这三点,所以对需要账号体系的站点,它是必选项而不是可选项。
九、上线检查清单
把要点做成一页清单,改完配置逐条核对:
location = /auth是否加了internal;,并已确认外部访问返回 404。- 鉴权端点是否只返回 200 / 401 / 403,没有返回 500 或 302(后者会被 Nginx 判为错误)。
- 原始 URI 是否通过自定义头传入,PHP 侧读取的是
HTTP_X_ORIGINAL_URI而不是REQUEST_URI。 - PHP 里是否对路径做了
realpath前缀校验,防住目录穿越。 - 受保护 location 上是否显式写了
auth_request,不依赖继承。 - 若开了缓存,缓存键是否包含身份变量,或是否对受保护目录关了缓存。
error_page 401 = @xxx;是否带等号,避免状态码没被覆盖导致 SEO 抓取异常。- 鉴权端点的单次响应时间是否在 5 毫秒以内(可用
curl -w '%{time_total}'反复测量取平均)。
这份清单看着琐碎,但每一条都对应一次真实踩坑。私有内容的访问控制属于「错一次就是数据泄露」的类别,多花十分钟核对,远比事后补救划算。