OpenResty Server 级别统一验证方案
方案概述
基于 access_by_lua_block 实现的 Server 级别统一验证方案。在 Nginx 处理请求的最早期阶段(access 阶段)进行鉴权判断,未通过验证的请求直接返回验证页,不会进入上游服务。
核心特点
| 特性 | 说明 |
|---|---|
| 早期拦截 | 在 access 阶段处理,比 content 阶段更早,性能更好 |
| Cookie 缓存 | 验证通过后设置短期 Cookie,避免频繁重复验证 |
| 同域探测 | 浏览器在同域下发起 GET 探测,自动携带当前站点的鉴权信息(Cookie/Token) |
| 无侵入性 | 验证页由 OpenResty 直接返回,不受上游 CSP 限制 |
工作原理
用户访问 /chromium/
│
▼
┌─────────────────┐
│ 检查 Cookie │◄── 已验证?──► 直接放行到上游
│ __probe_ok=1 │
└─────────────────┘
│ 无 Cookie
▼
┌─────────────────┐
│ 返回验证 HTML │
│ (内嵌 JS 探测) │
└─────────────────┘
│
▼
浏览器执行 JS ──► 发起 GET 探测 ──► 主站接口返回 JSON
│ │
│ 200 + code:0 │ 401/403
▼ ▼
设置 Cookie 跳转登录页
刷新进入上游
完整配置示例
location ^~ /chromium/ {
access_by_lua_block {
local probe_cookie_max_age = 60
-- 已通过验证:放行进入上游
local ck = ngx.var.http_cookie or ""
if ck:find("__probe_ok=1", 1, true) then
return
end
-- 未通过验证:返回验证页(不走上游)
local scheme = ngx.var.scheme or "http"
local host = ngx.var.http_host or ngx.var.host
local probe_url = scheme .. "://" .. host .. "/app-center/v1/app/list?language=zh-CN"
ngx.status = ngx.HTTP_OK
ngx.header["Content-Type"] = "text/html; charset=utf-8"
ngx.header["Cache-Control"] = "no-store"
ngx.header["Pragma"] = "no-cache"
-- 用 JS 字符串字面量时做最小转义(防止引号/反斜杠破坏脚本)
local probe_url_js = probe_url
probe_url_js = probe_url_js:gsub("\\", "\\\\")
probe_url_js = probe_url_js:gsub("\"", "\\\"")
-- 分段输出:避免任何长括号字符串
ngx.print('<!doctype html>\n')
ngx.print('<html>\n<head>\n')
ngx.print(' <meta charset="utf-8" />\n')
ngx.print(' <meta name="viewport" content="width=device-width,initial-scale=1" />\n')
ngx.print(' <title>验证中</title>\n')
ngx.print('</head>\n')
ngx.print('<body style="font-family: sans-serif; padding: 16px;">\n')
ngx.print(' <div id="msg">验证中,请稍候...</div>\n')
ngx.print('<script>\n')
ngx.print('(function () {\n')
ngx.print(' var msg = document.getElementById("msg");\n')
ngx.print(' var baseUrl = "' .. probe_url_js .. '";\n')
ngx.print(' var url = baseUrl + "&_ts=" + Date.now();\n')
ngx.print(' function gotoLogin(reason) {\n')
ngx.print(' try { sessionStorage.removeItem("__probe_try_count"); } catch (e) {}\n')
ngx.print(' msg.textContent = reason || "未登录或无权限访问,即将跳转到登录页面...";\n')
ngx.print(' var loginUrl = location.origin + "/login?redirect_uri=" + encodeURIComponent(location.href);\n')
ngx.print(' setTimeout(function () { location.href = loginUrl; }, 2000);\n')
ngx.print(' }\n')
ngx.print(' var maxTry = 6;\n')
ngx.print(' var tryKey = "__probe_try_count";\n')
ngx.print(' var tryCount = 0;\n')
ngx.print(' try { tryCount = parseInt(sessionStorage.getItem(tryKey) || "0", 10) || 0; } catch (e) {}\n')
ngx.print(' async function run() {\n')
ngx.print(' tryCount += 1;\n')
ngx.print(' try { sessionStorage.setItem(tryKey, String(tryCount)); } catch (e) {}\n')
ngx.print(' msg.textContent = "验证中,请稍候...";\n')
ngx.print(' if (tryCount > maxTry) {\n')
ngx.print(' gotoLogin("认证失败,请先登录后再访问(即将跳转)...");\n')
ngx.print(' return;\n')
ngx.print(' }\n')
ngx.print(' var resp;\n')
ngx.print(' try {\n')
ngx.print(' resp = await fetch(url, { method: "GET", credentials: "include", cache: "no-store" });\n')
ngx.print(' } catch (e) {\n')
ngx.print(' setTimeout(function(){ location.reload(); }, 800);\n')
ngx.print(' return;\n')
ngx.print(' }\n')
ngx.print(' if (!resp) {\n')
ngx.print(' setTimeout(function(){ location.reload(); }, 800);\n')
ngx.print(' return;\n')
ngx.print(' }\n')
ngx.print(' if (resp.status === 401 || resp.status === 403) {\n')
ngx.print(' gotoLogin("未登录或无权限访问(即将跳转)...");\n')
ngx.print(' return;\n')
ngx.print(' }\n')
ngx.print(' if (resp.status !== 200) {\n')
ngx.print(' setTimeout(function(){ location.reload(); }, 800);\n')
ngx.print(' return;\n')
ngx.print(' }\n')
ngx.print(' var data;\n')
ngx.print(' try { data = await resp.json(); } catch (e) {\n')
ngx.print(' setTimeout(function(){ location.reload(); }, 800);\n')
ngx.print(' return;\n')
ngx.print(' }\n')
ngx.print(' if (!data || data.code !== 0) {\n')
ngx.print(' setTimeout(function(){ location.reload(); }, 800);\n')
ngx.print(' return;\n')
ngx.print(' }\n')
ngx.print(' document.cookie = "__probe_ok=1; Max-Age=' .. probe_cookie_max_age .. '; Path=/; SameSite=Lax; Secure";\n')
ngx.print(' try { sessionStorage.removeItem(tryKey); } catch (e) {}\n')
ngx.print(' msg.textContent = "验证通过,正在进入...";\n')
ngx.print(' location.replace(location.pathname);\n')
ngx.print(' }\n')
ngx.print(' run();\n')
ngx.print('})();\n')
ngx.print('</script>\n')
ngx.print('</body>\n</html>\n')
return ngx.exit(ngx.HTTP_OK)
}
# 只有通过验证的请求才会走到这里
proxy_pass https://127.0.0.1:5667;
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_set_header REMOTE-HOST $remote_addr;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $http_connection;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Port $server_port;
proxy_http_version 1.1;
add_header X-Cache $upstream_cache_status;
proxy_ssl_server_name off;
proxy_ssl_name $proxy_host;
}
配置说明
需要修改的参数
| 参数 | 位置 | 说明 |
|---|---|---|
/chromium/ | location 行 | 监控入口路径,按需修改 |
probe_cookie_max_age = 60 | Lua 变量 | Cookie 有效期(秒),默认60秒 |
https://127.0.0.1:5667 | proxy_pass | 上游服务地址 |
/app-center/v1/app/list | probe_url | 探测接口地址,需返回 JSON |
探测接口要求
验证页会向探测接口发送 GET 请求,接口需满足:
- 已登录/有权限:返回 HTTP 200,JSON 格式,包含
code: 0 - 未登录:返回 HTTP 401 或 403
- 支持 CORS:同域请求,无需额外跨域配置
示例成功响应:
{
"code": 0,
"message": "success",
"data": [...]
}
多路径统一配置
推荐方式:Lua 文件引用
当代码较长时,推荐将 Lua 代码保存到文件,保持可读性和可维护性:
# nginx.conf
# 复用同一个 Lua 文件
define VALIDATE_PROBE access_by_lua_file /etc/nginx/lua/validate_probe.lua;
location ^~ /chromium/ {
VALIDATE_PROBE
proxy_pass https://127.0.0.1:5667;
# ... proxy 配置
}
location ^~ /app/ {
VALIDATE_PROBE
proxy_pass https://127.0.0.1:5668;
# ... proxy 配置
}
安全与优化建议
1. Cookie 安全
| 属性 | 建议 |
|---|---|
Secure | HTTPS 环境必须保留,HTTP 调试需移除 |
SameSite=Lax | 防止 CSRF,平衡安全性与可用性 |
Max-Age | 建议 60-300 秒,太短影响体验,太长降低安全性 |
2. 重试机制
- 最大重试次数:
maxTry = 6 - 重试间隔:网络错误后 800ms 自动刷新
- 防刷限制:超过重试次数强制跳转登录页
3. 性能优化
- 验证页极简化,无外部资源
- 使用
no-store缓存控制,确保每次都重新验证 - Lua 代码预编译,执行效率高
常见问题
Q1: 为什么验证通过了但还是进不去上游?
该方案仅确保浏览器已携带主站鉴权信息,不绕过上游自身的鉴权逻辑。如果上游 /chromium/ 还有独立鉴权,需另行处理。
Q2: 如何调试验证流程?
- 打开浏览器开发者工具(F12)
- 查看 Network 面板,观察
app-center/v1/app/list请求 - 检查 Console 面板是否有 JS 错误
- 确认 Cookie 是否正常设置
Q3: 支持 WebSocket 吗?
支持。proxy_set_header Upgrade 和 proxy_set_header Connection 已配置,WebSocket 可正常升级。
Q4: 如何延长验证有效期?
修改 probe_cookie_max_age 变量值(单位:秒)。注意:延长有效期会降低安全性,建议配合业务需求调整。
与 body_filter 方案的对比
| 特性 | Server 级别验证(本方案) | body_filter 注入 |
|---|---|---|
| 执行阶段 | access | content 之后 |
| CSP 限制 | 无 | 受上游 CSP 限制 |
| 性能 | 更优(早期拦截) | 需要处理响应体 |
| 复杂度 | 低 | 较高 |
| 适用场景 | 所有场景 | 无 CSP 限制的站点 |