使用 OpenResty 和 Lua 实现网关统一认证中心:全面解析与实战

目录

  1. 网关统一认证中心的背景与需求
  2. OpenResty 的关键技术点
  3. 实现步骤:构建认证中心
  4. 高并发场景下的共享内存优化
  5. 其他优化与建议
  6. 完整 Nginx 配置文件与 Lua 脚本
  7. 部署与测试
  8. 总结

网关统一认证中心的背景与需求

在微服务架构中,网关是统一入口,负责路由、认证、限流等功能。统一认证中心是网关的核心组件,用于验证客户端请求的合法性(通常通过 token)。本教程的目标是使用 OpenResty 实现一个高效的认证中心,满足以下需求:

  1. token 存储:利用 Nginx 的共享内存存储 token,确保高性能查找。
  2. token 推送接口:提供 /api/v1/tokens 接口,接受认证后的 token 并存入共享内存。
  3. token 验证:在请求处理阶段检查 header 中的 token,若有效则代理到后端,否则返回 401。
  4. 高并发优化:解决多个 Nginx Worker 对共享内存的竞争问题。
  5. 扩展性:提供优化建议,支持分布式部署和复杂场景。

OpenResty 的非阻塞架构和 Lua 的轻量高效使其非常适合实现这一功能。接下来,我们将逐一讲解实现步骤。

OpenResty 的关键技术点

在实现认证中心之前,了解以下 OpenResty 技术点将帮助你更好地理解代码:

  • Nginx 共享内存 (ngx.shared):Nginx 提供共享内存字典,允许多个 Worker 进程共享数据,适合存储 token。
  • Lua 脚本阶段access_by_lua* 适合认证检查,content_by_lua* 适合处理 API 请求。
  • Nginx 代理:通过 proxy_pass 将验证通过的请求转发到后端服务器。
  • LuaJIT:OpenResty 使用 LuaJIT 编译 Lua 脚本,性能接近 C 语言。
  • 非阻塞 I/O:OpenResty 的事件驱动模型确保高并发处理能力。

实现步骤:构建认证中心

步骤 1:配置 Nginx 共享内存存储 token

作用与原理

Nginx 的共享内存通过 lua_shared_dict 指令定义,允许多个 Worker 进程访问同一块内存区域。我们将使用它存储 token,格式为 key: token 值, value: 用户信息或其他元数据

  • 适用场景:存储高频访问的 token 数据,减少外部数据库查询。
  • 注意事项:共享内存大小固定,需合理规划;操作非线程安全,需注意并发竞争。

配置共享内存

在 Nginx 配置文件(nginx.conf)的 http 块中添加以下指令:

1
2
3
4
http {
    # 定义共享内存,名为 tokens,大小 10MB
    lua_shared_dict tokens 10m;
}

说明

  • tokens 是共享内存的名称,10m 表示分配 10MB 内存。
  • 可根据 token 数量调整大小(每个 token 约占用几 KB)。

初始化共享内存

init_by_lua* 阶段初始化共享内存(可选,用于预加载数据):

1
2
3
4
5
init_by_lua_block {
    -- 可选:预加载 token(例如从文件或数据库)
    local tokens = ngx.shared.tokens
    tokens:set("sample_token_123", "user_id:1001,expire:2025-12-31")
}

说明

  • ngx.shared.tokens 是共享内存对象。
  • set 方法存储 key-value 对,value 可包含用户 ID、过期时间等。

步骤 2:实现 /api/v1/tokens 接口存储 token

作用与原理

/api/v1/tokens 是一个 POST 接口,接受外部系统推送的认证后 token。Lua 脚本解析请求体,将 token 存入共享内存。

  • 适用场景:外部认证服务(如 OAuth 服务器)推送 token。
  • 注意事项:需验证请求来源,防止未授权推送;建议设置 token 过期时间。

Nginx 配置

nginx.confserver 块中添加 location:

1
2
3
4
5
6
7
8
server {
    listen 80;
    server_name auth.example.com;

    location /api/v1/tokens {
        content_by_lua_file /path/to/lua/push_token.lua;
    }
}

Lua 脚本 (push_token.lua)

实现 token 推送逻辑:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
-- /path/to/lua/push_token.lua
local cjson = require "cjson"

-- 读取 POST 请求体
ngx.req.read_body()
local body = ngx.req.get_body_data()
if not body then
    ngx.status = ngx.HTTP_BAD_REQUEST
    ngx.say(cjson.encode({error = "Missing request body"}))
    return
end

-- 解析 JSON
local data, err = cjson.decode(body)
if not data then
    ngx.status = ngx.HTTP_BAD_REQUEST
    ngx.say(cjson.encode({error = "Invalid JSON: " .. (err or "unknown")}))
    return
end

-- 验证必要字段
local token = data.token
local user_info = data.user_info
local expire = data.expire
if not token or not user_info or not expire then
    ngx.status = ngx.HTTP_BAD_REQUEST
    ngx.say(cjson.encode({error = "Missing token, user_info, or expire"}))
    return
end

-- 存储到共享内存
local tokens = ngx.shared.tokens
local success, err = tokens:set(token, cjson.encode({user_info = user_info, expire = expire}))
if not success then
    ngx.status = ngx.HTTP_INTERNAL_SERVER_ERROR
    ngx.say(cjson.encode({error = "Failed to store token: " .. (err or "unknown")}))
    return
end

-- 返回成功响应
ngx.status = ngx.HTTP_OK
ngx.say(cjson.encode({message = "Token stored successfully"}))

代码说明

  • 使用 cjson 解析 POST 请求的 JSON 体,期望包含 tokenuser_infoexpire 字段。
  • 验证字段完整性,防止无效数据。
  • 使用 ngx.shared.tokens:set 存储 token,value 为 JSON 编码的用户信息和过期时间。
  • 返回 JSON 响应,指示成功或失败。

示例请求

1
2
3
curl -X POST http://auth.example.com/api/v1/tokens \
-H "Content-Type: application/json" \
-d '{"token":"xyz123","user_info":"user_id:1001","expire":"2025-12-31"}'

预期响应

1
{"message":"Token stored successfully"}

步骤 3:在 access_by_lua* 阶段验证 token

作用与原理

access_by_lua* 是访问控制阶段,适合检查请求的认证状态。我们将检查 header 中的 Authorization 字段(如 Bearer xyz123),验证 token 是否存在于共享内存。

  • 适用场景:拦截未认证请求,确保只有合法用户访问后端。
  • 注意事项:快速执行,减少延迟;支持 token 过期检查。

Nginx 配置

nginx.conflocation 块中添加 access_by_lua_file

1
2
3
4
location / {
    access_by_lua_file /path/to/lua/check_token.lua;
    proxy_pass http://backend;
}

Lua 脚本 (check_token.lua)

实现 token 验证逻辑:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
-- /path/to/lua/check_token.lua
local cjson = require "cjson"

-- 获取 Authorization header
local auth_header = ngx.var.http_authorization
if not auth_header then
    ngx.status = ngx.HTTP_UNAUTHORIZED
    ngx.header["WWW-Authenticate"] = 'Bearer realm="auth.example.com"'
    ngx.say(cjson.encode({error = "Missing Authorization header"}))
    return ngx.exit(ngx.HTTP_UNAUTHORIZED)
end

-- 提取 Bearer token
local token = auth_header:match("^Bearer%s+(.+)$")
if not token then
    ngx.status = ngx.HTTP_UNAUTHORIZED
    ngx.say(cjson.encode({error = "Invalid Authorization header"}))
    return ngx.exit(ngx.HTTP_UNAUTHORIZED)
end

-- 检查 token 是否存在
local tokens = ngx.shared.tokens
local value = tokens:get(token)
if not value then
    ngx.status = ngx.HTTP_UNAUTHORIZED
    ngx.say(cjson.encode({error = "Invalid or expired token"}))
    return ngx.exit(ngx.HTTP_UNAUTHORIZED)
end

-- 解析 token 元数据
local data, err = cjson.decode(value)
if not data then
    ngx.log(ngx.ERR, "Failed to decode token data: ", err)
    ngx.status = ngx.HTTP_INTERNAL_SERVER_ERROR
    ngx.say(cjson.encode({error = "Internal server error"}))
    return ngx.exit(ngx.HTTP_INTERNAL_SERVER_ERROR)
end

-- 检查 token 是否过期
local expire = data.expire
local current_time = os.date("%Y-%m-%d")
if expire < current_time then
    tokens:delete(token) -- 删除过期 token
    ngx.status = ngx.HTTP_UNAUTHORIZED
    ngx.say(cjson.encode({error = "Token expired"}))
    return ngx.exit(ngx.HTTP_UNAUTHORIZED)
end

-- 认证通过,设置下游变量
ngx.var.user_info = data.user_info

代码说明

  • 检查 Authorization header,提取 Bearer token。
  • 使用 tokens:get 查询共享内存,验证 token 存在性。
  • 解析存储的 JSON 数据,检查 expire 字段是否过期。
  • 若认证通过,设置 ngx.var.user_info 供后端使用;否则返回 401。

示例请求

1
curl http://auth.example.com/ -H "Authorization: Bearer xyz123"

预期响应(认证失败)

1
{"error":"Invalid or expired token"}

步骤 4:代理请求到后端服务器

作用与原理

认证通过后,OpenResty 使用 proxy_pass 将请求转发到后端服务器。可以通过 Nginx 的 upstream 模块定义后端服务器集群。

  • 适用场景:将合法请求路由到微服务。
  • 注意事项:确保后端服务器支持用户信息的传递(如通过 header)。

Nginx 配置

定义 upstreamproxy_pass

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
upstream backend {
    server 127.0.0.1:8080;
    server 127.0.0.1:8081;
}

server {
    listen 80;
    server_name auth.example.com;

    location / {
        access_by_lua_file /path/to/lua/check_token.lua;
        proxy_set_header X-User-Info $user_info; # 传递用户信息
        proxy_pass http://backend;
    }
}

说明

  • upstream backend 定义后端服务器集群。
  • proxy_set_headeruser_info 传递给后端。
  • proxy_pass 转发请求到 backend

高并发场景下的共享内存优化

问题分析

Nginx 的共享内存(ngx.shared)在多 Worker 进程下是非线程安全的,多个 Worker 可能同时读写同一 key,导致竞争问题。在高并发场景下(如每秒数万请求),可能出现以下问题:

  • 锁竞争:共享内存操作涉及内部锁,高并发下锁竞争会导致性能下降。
  • 内存溢出:若 token 数量过多,共享内存可能耗尽。
  • 数据一致性:快速读写可能导致数据不一致(如覆盖有效 token)。

优化策略

以下是针对共享内存竞争的优化方案:

  1. 减少写操作

    • 批量写入:在 /api/v1/tokens 接口中,允许批量推送 token,减少单次写操作。
    • 异步写入:使用 ngx.timer 将 token 写入操作放入后台任务,降低主请求路径的竞争。
    • 示例(异步写入)
      1
      2
      3
      4
      5
      6
      7
      
      local function async_store_token(token, value)
          local tokens = ngx.shared.tokens
          tokens:set(token, value)
      end
      
      -- 在 push_token.lua 中
      ngx.timer.at(0, async_store_token, token, cjson.encode({user_info = user_info, expire = expire}))
      
  2. 使用外部存储(如 Redis)

    • 场景:当 token 数量较大或需要分布式部署时,使用 Redis 替代共享内存。
    • 实现:引入 lua-resty-redis 模块,存储 token 到 Redis。
    • 示例
      1
      2
      3
      4
      5
      6
      7
      8
      
      local redis = require "resty.redis"
      local red = redis:new()
      red:set_timeout(1000)
      local ok, err = red:connect("127.0.0.1", 6379)
      if ok then
          red:set("token:" .. token, value)
          red:expire("token:" .. token, 86400) -- 过期时间 1 天
      end
      
  3. 分区共享内存

    • 场景:将 token 分片存储到多个共享内存区域,减少单一字典的竞争。
    • 实现:定义多个 lua_shared_dict(如 tokens_1tokens_2),根据 token 的哈希值选择存储区域。
    • 示例配置
      1
      2
      
      lua_shared_dict tokens_1 10m;
      lua_shared_dict tokens_2 10m;
      
      1
      2
      3
      4
      
      local function get_dict(token)
          local hash = ngx.crc32_short(token)
          return hash % 2 == 0 and ngx.shared.tokens_1 or ngx.shared.tokens_2
      end
      
  4. 缓存热点 token

    • 场景:高频访问的 token 可缓存到 Worker 本地 Lua 表,减少共享内存查询。
    • 实现:在 init_worker_by_lua* 初始化本地缓存,定期同步。
    • 示例
       1
       2
       3
       4
       5
       6
       7
       8
       9
      10
      
      init_worker_by_lua_block {
          _G.local_cache = {}
      }
      
      -- 在 check_token.lua 中
      local token = auth_header:match("^Bearer%s+(.+)$")
      local value = _G.local_cache[token] or tokens:get(token)
      if value then
          _G.local_cache[token] = value -- 更新本地缓存
      end
      
  5. 限流写操作

    • 场景:限制 /api/v1/tokens 接口的请求速率,减少共享内存写压力。
    • 实现:在 access_by_lua* 中使用 lua-resty-limit-req 模块。
    • 示例
       1
       2
       3
       4
       5
       6
       7
       8
       9
      10
      11
      12
      13
      14
      15
      16
      17
      
      lua_shared_dict limit_req 10m;
      location /api/v1/tokens {
          access_by_lua_block {
              local limit_req = require "resty.limit.req"
              local lim, err = limit_req.new("limit_req", 100, 50) -- 100 req/s, burst 50
              local delay, err = lim:incoming("tokens_api", true)
              if not delay then
                  ngx.status = ngx.HTTP_TOO_MANY_REQUESTS
                  ngx.say(cjson.encode({error = "Rate limit exceeded"}))
                  return ngx.exit(ngx.HTTP_TOO_MANY_REQUESTS)
              end
              if delay > 0 then
                  ngx.sleep(delay)
              end
          }
          content_by_lua_file /path/to/lua/push_token.lua;
      }
      

其他优化与建议

  1. token 过期管理

    • 定期清理过期 token,防止共享内存溢出。
    • 实现:在 timer_by_lua* 中运行清理任务。
    • 示例
       1
       2
       3
       4
       5
       6
       7
       8
       9
      10
      11
      12
      13
      14
      
      init_by_lua_block {
          local function clean_expired()
              local tokens = ngx.shared.tokens
              for _, token in ipairs(tokens:get_keys()) do
                  local value = tokens:get(token)
                  local data = cjson.decode(value)
                  if data.expire < os.date("%Y-%m-%d") then
                      tokens:delete(token)
                  end
              end
              ngx.timer.at(3600, clean_expired) -- 每小时运行
          end
          ngx.timer.at(0, clean_expired)
      }
      
  2. 分布式部署

    • 使用 Redis 或数据库作为中央存储,支持多实例网关。
    • 部署 Nginx 集群,结合 DNS 或负载均衡器分发流量。
  3. 安全加固

    • /api/v1/tokens 接口添加 IP 白名单或 API 密钥验证。
    • 使用 HTTPS 保护 token 传输。
    • 示例(IP 白名单)
      1
      2
      3
      4
      5
      
      location /api/v1/tokens {
          allow 192.168.1.0/24;
          deny all;
          content_by_lua_file /path/to/lua/push_token.lua;
      }
      
  4. 日志与监控

    • log_by_lua* 记录认证失败的请求,发送到外部系统(如 ELK)。
    • 使用 Prometheus 和 Grafana 监控共享内存使用率和认证性能。
  5. 错误处理

    • 为所有 Lua 脚本添加错误捕获(pcall),防止脚本异常导致服务中断。
    • 示例
      1
      2
      3
      4
      5
      6
      7
      8
      9
      
      local ok, err = pcall(function()
          -- 脚本逻辑
      end)
      if not ok then
          ngx.log(ngx.ERR, "Lua error: ", err)
          ngx.status = ngx.HTTP_INTERNAL_SERVER_ERROR
          ngx.say(cjson.encode({error = "Internal server error"}))
          return ngx.exit(ngx.HTTP_INTERNAL_SERVER_ERROR)
      end
      

完整 Nginx 配置文件与 Lua 脚本

Nginx 配置文件 (nginx.conf)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
http {
    lua_shared_dict tokens 10m;
    lua_shared_dict limit_req 10m;

    upstream backend {
        server 127.0.0.1:8080;
        server 127.0.0.1:8081;
    }

    server {
        listen 80;
        server_name auth.example.com;

        location /api/v1/tokens {
            access_by_lua_block {
                local limit_req = require "resty.limit.req"
                local lim, err = limit_req.new("limit_req", 100, 50)
                local delay, err = lim:incoming("tokens_api", true)
                if not delay then
                    ngx.status = ngx.HTTP_TOO_MANY_REQUESTS
                    ngx.say(cjson.encode({error = "Rate limit exceeded"}))
                    return ngx.exit(ngx.HTTP_TOO_MANY_REQUESTS)
                end
                if delay > 0 then
                    ngx.sleep(delay)
                end
            }
            content_by_lua_file /path/to/lua/push_token.lua;
        }

        location / {
            access_by_lua_file /path/to/lua/check_token.lua;
            proxy_set_header X-User-Info $user_info;
            proxy_pass http://backend;
        }
    }
}

Lua 脚本 (push_token.lua)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
local cjson = require "cjson"

ngx.req.read_body()
local body = ngx.req.get_body_data()
if not body then
    ngx.status = ngx.HTTP_BAD_REQUEST
    ngx.say(cjson.encode({error = "Missing request body"}))
    return
end

local data, err = cjson.decode(body)
if not data then
    ngx.status = ngx.HTTP_BAD_REQUEST
    ngx.say(cjson.encode({error = "Invalid JSON: " .. (err or "unknown")}))
    return
end

local token = data.token
local user_info = data.user_info
local expire = data.expire
if not token or not user_info or not expire then
    ngx.status = ngx.HTTP_BAD_REQUEST
    ngx.say(cjson.encode({error = "Missing token, user_info, or expire"}))
    return
end

local tokens = ngx.shared.tokens
local success, err = tokens:set(token, cjson.encode({user_info = user_info, expire = expire}))
if not success then
    ngx.status = ngx.HTTP_INTERNAL_SERVER_ERROR
    ngx.say(cjson.encode({error = "Failed to store token: " .. (err or "unknown")}))
    return
end

ngx.status = ngx.HTTP_OK
ngx.say(cjson.encode({message = "Token stored successfully"}))

Lua 脚本 (check_token.lua)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
local cjson = require "cjson"

local auth_header = ngx.var.http_authorization
if not auth_header then
    ngx.status = ngx.HTTP_UNAUTHORIZED
    ngx.header["WWW-Authenticate"] = 'Bearer realm="auth.example.com"'
    ngx.say(cjson.encode({error = "Missing Authorization header"}))
    return ngx.exit(ngx.HTTP_UNAUTHORIZED)
end

local token = auth_header:match("^Bearer%s+(.+)$")
if not token then
    ngx.status = ngx.HTTP_UNAUTHORIZED
    ngx.say(cjson.encode({error = "Invalid Authorization header"}))
    return ngx.exit(ngx.HTTP_UNAUTHORIZED)
end

local tokens = ngx.shared.tokens
local value = tokens:get(token)
if not value then
    ngx.status = ngx.HTTP_UNAUTHORIZED
    ngx.say(cjson.encode({error = "Invalid or expired token"}))
    return ngx.exit(ngx.HTTP_UNAUTHORIZED)
end

local data, err = cjson.decode(value)
if not data then
    ngx.log(ngx.ERR, "Failed to decode token data: ", err)
    ngx.status = ngx.HTTP_INTERNAL_SERVER_ERROR
    ngx.say(cjson.encode({error = "Internal server error"}))
    return ngx.exit(ngx.HTTP_INTERNAL_SERVER_ERROR)
end

local expire = data.expire
local current_time = os.date("%Y-%m-%d")
if expire < current_time then
    tokens:delete(token)
    ngx.status = ngx.HTTP_UNAUTHORIZED
    ngx.say(cjson.encode({error = "Token expired"}))
    return ngx.exit(ngx.HTTP_UNAUTHORIZED)
end

ngx.var.user_info = data.user_info

部署与测试

部署步骤

  1. 安装 OpenResty:从 openresty.org 下载并安装,支持 Lua 模块。
  2. 配置 Nginx:将上述 nginx.conf 保存到 OpenResty 的配置目录。
  3. 创建 Lua 脚本:将 push_token.luacheck_token.lua 保存到指定路径。
  4. 启动服务:运行 openresty -t 检查配置,执行 openresty 启动。
  5. 测试接口
    • 推送 token:curl -X POST http://auth.example.com/api/v1/tokens -H "Content-Type: application/json" -d '{"token":"xyz123","user_info":"user_id:1001","expire":"2025-12-31"}'
    • 验证请求:curl http://auth.example.com/ -H "Authorization: Bearer xyz123"

测试预期

  • 推送 token 成功,返回 {"message":"Token stored successfully"}
  • 使用有效 token 访问,请求被代理到后端。
  • 使用无效 token 访问,返回 401 和 {"error":"Invalid or expired token"}

总结

通过 OpenResty 和 Lua,我们实现了一个高效的网关统一认证中心,利用共享内存存储 token,提供了 /api/v1/tokens 接口和 token 验证功能。针对高并发场景,我们提出了异步写入、Redis 替代、分区存储等优化策略,确保系统在高负载下的稳定性。本教程的代码示例简单易懂,适合初学者快速上手,同时为进阶用户提供了丰富的优化思路。

评论 0