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 = 60Lua 变量Cookie 有效期(秒),默认60秒
https://127.0.0.1:5667proxy_pass上游服务地址
/app-center/v1/app/listprobe_url探测接口地址,需返回 JSON

探测接口要求

验证页会向探测接口发送 GET 请求,接口需满足:

  1. 已登录/有权限:返回 HTTP 200,JSON 格式,包含 code: 0
  2. 未登录:返回 HTTP 401 或 403
  3. 支持 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 配置
}

安全与优化建议

属性建议
SecureHTTPS 环境必须保留,HTTP 调试需移除
SameSite=Lax防止 CSRF,平衡安全性与可用性
Max-Age建议 60-300 秒,太短影响体验,太长降低安全性

2. 重试机制

  • 最大重试次数:maxTry = 6
  • 重试间隔:网络错误后 800ms 自动刷新
  • 防刷限制:超过重试次数强制跳转登录页

3. 性能优化

  • 验证页极简化,无外部资源
  • 使用 no-store 缓存控制,确保每次都重新验证
  • Lua 代码预编译,执行效率高

常见问题

Q1: 为什么验证通过了但还是进不去上游?

该方案仅确保浏览器已携带主站鉴权信息,不绕过上游自身的鉴权逻辑。如果上游 /chromium/ 还有独立鉴权,需另行处理。

Q2: 如何调试验证流程?

  1. 打开浏览器开发者工具(F12)
  2. 查看 Network 面板,观察 app-center/v1/app/list 请求
  3. 检查 Console 面板是否有 JS 错误
  4. 确认 Cookie 是否正常设置

Q3: 支持 WebSocket 吗?

支持。proxy_set_header Upgradeproxy_set_header Connection 已配置,WebSocket 可正常升级。

Q4: 如何延长验证有效期?

修改 probe_cookie_max_age 变量值(单位:秒)。注意:延长有效期会降低安全性,建议配合业务需求调整。


与 body_filter 方案的对比

特性Server 级别验证(本方案)body_filter 注入
执行阶段accesscontent 之后
CSP 限制受上游 CSP 限制
性能更优(早期拦截)需要处理响应体
复杂度较高
适用场景所有场景无 CSP 限制的站点