强曰为道
与天地相似,故不违。知周乎万物,而道济天下,故不过。旁行而不流,乐天知命,故不忧.
文档目录

OpenResty 高性能网关开发教程 / 第 07 章 - 认证与鉴权

第 07 章 - 认证与鉴权

7.1 认证 vs 鉴权

认证(Authentication)─→ "你是谁?"
  验证请求者的身份
  方式:JWT、API Key、OAuth2、mTLS

鉴权(Authorization)─→ "你能做什么?"
  验证请求者的权限
  方式:RBAC、ACL、Policy

7.2 JWT 认证

JWT(JSON Web Token)是目前最流行的无状态认证方案。

JWT 结构

Header.Payload.Signature

Header:    {"alg":"HS256","typ":"JWT"}          → Base64URL 编码
Payload:   {"sub":"user123","exp":1704067200}   → Base64URL 编码
Signature: HMACSHA256(header + "." + payload, secret)

7.2.1 安装 lua-resty-jwt

# 使用 OPM 安装
sudo opm get SkyLothar/lua-resty-jwt

# 或手动下载
cd /usr/local/openresty/lualib
git clone https://github.com/SkyLothar/lua-resty-jwt.git resty/jwt
git clone https://github.com/SkyLothar/lua-resty-string.git resty/string

7.2.2 JWT 认证模块

-- /usr/local/openresty/lua/auth/jwt_auth.lua
local _M = {}

local jwt = require "resty.jwt"
local cjson = require "cjson"

-- 配置
local config = {
    secret = os.getenv("JWT_SECRET") or "your-256-bit-secret-key-change-in-production",
    algorithm = "HS256",
    -- Token 来源
    token_header = "Authorization",
    token_prefix = "Bearer ",
    -- Cookie 备选
    token_cookie = "access_token",
    -- 白名单路径(不需要认证)
    whitelist = {
        "/api/health",
        "/api/version",
        "/api/auth/login",
        "/api/auth/register",
    },
}

-- 检查路径是否在白名单中
local function is_whitelisted(uri)
    for _, path in ipairs(config.whitelist) do
        if uri == path or uri:match("^" .. path:gsub("%-", "%%-") .. "/") then
            return true
        end
    end
    return false
end

-- 从请求中提取 Token
local function extract_token()
    -- 方式 1:Authorization Header
    local auth_header = ngx.req.get_headers()[config.token_header]
    if auth_header then
        local token = auth_header:match("^" .. config.token_prefix .. "(.+)$")
        if token then
            return token
        end
    end

    -- 方式 2:Cookie
    local cookie_token = ngx.var["cookie_" .. config.token_cookie]
    if cookie_token then
        return cookie_token
    end

    -- 方式 3:查询参数
    local args = ngx.req.get_uri_args()
    if args.access_token then
        return args.access_token
    end

    return nil
end

-- 验证 JWT
function _M.authenticate()
    local uri = ngx.var.uri

    -- 白名单检查
    if is_whitelisted(uri) then
        return true
    end

    -- 提取 Token
    local token = extract_token()
    if not token then
        ngx.status = 401
        ngx.header["Content-Type"] = "application/json"
        ngx.say(cjson.encode({
            error = "Unauthorized",
            message = "Missing authentication token",
        }))
        return false
    end

    -- 尝试从缓存获取
    local cache = ngx.shared.jwt_cache
    local cached = cache:get(token)
    if cached then
        local payload = cjson.decode(cached)
        ngx.var.user_id = payload.sub or ""
        ngx.var.user_role = payload.role or "user"
        return true
    end

    -- 验证 JWT
    local jwt_obj = jwt:verify(config.secret, token)

    if not jwt_obj.verified then
        ngx.log(ngx.WARN, "JWT verification failed: ", jwt_obj.reason)
        ngx.status = 401
        ngx.header["Content-Type"] = "application/json"
        ngx.say(cjson.encode({
            error = "Unauthorized",
            message = "Invalid or expired token",
            reason = jwt_obj.reason,
        }))
        return false
    end

    -- 检查过期时间(jwt:verify 已自动检查,此处双重保险)
    local payload = jwt_obj.payload
    if payload.exp and payload.exp < ngx.time() then
        ngx.status = 401
        ngx.say(cjson.encode({error = "Token expired"}))
        return false
    end

    -- 缓存验证结果(Token 未过期前有效)
    local ttl = 300  -- 默认缓存 5 分钟
    if payload.exp then
        ttl = math.min(ttl, payload.exp - ngx.time())
    end
    if ttl > 0 then
        cache:set(token, cjson.encode(payload), ttl)
    end

    -- 设置用户信息到变量
    ngx.var.user_id = payload.sub or ""
    ngx.var.user_role = payload.role or "user"

    -- 将用户信息传递给后端
    ngx.req.set_header("X-User-ID", payload.sub or "")
    ngx.req.set_header("X-User-Role", payload.role or "user")
    ngx.req.set_header("X-User-Email", payload.email or "")

    return true
end

-- 生成 JWT Token(用于登录接口)
function _M.generate_token(user_data, expires_in)
    expires_in = expires_in or 7200  -- 默认 2 小时

    local payload = {
        sub = user_data.id,
        email = user_data.email,
        role = user_data.role or "user",
        iat = ngx.time(),
        exp = ngx.time() + expires_in,
        iss = "openresty-gateway",
    }

    local token = jwt:sign(config.secret, {
        header = {typ = "JWT", alg = config.algorithm},
        payload = payload,
    })

    return token, payload
end

-- 刷新 Token
function _M.refresh_token(current_token)
    local jwt_obj = jwt:verify(config.secret, current_token)
    if not jwt_obj.verified then
        return nil, "Invalid token"
    end

    local payload = jwt_obj.payload
    -- 检查是否在刷新窗口内(过期后 30 分钟内可刷新)
    if payload.exp and (ngx.time() - payload.exp) > 1800 then
        return nil, "Token expired too long ago"
    end

    -- 生成新 Token
    return _M.generate_token({
        id = payload.sub,
        email = payload.email,
        role = payload.role,
    })
end

return _M

7.2.3 Nginx 配置

http {
    lua_shared_dict jwt_cache 20m;

    server {
        listen 8080;

        # 通用变量
        set $user_id "";
        set $user_role "";

        # 认证中间件
        access_by_lua_block {
            local jwt_auth = require "auth.jwt_auth"
            if not jwt_auth.authenticate() then
                return ngx.exit(ngx.HTTP_UNAUTHORIZED)
            end
        }

        # 登录接口(不需要认证,在白名单中)
        location /api/auth/login {
            content_by_lua_block {
                local jwt_auth = require "auth.jwt_auth"
                local cjson = require "cjson"

                ngx.req.read_body()
                local body = cjson.decode(ngx.req.get_body_data())

                -- 验证用户名密码(示例)
                if body.username == "admin" and body.password == "password" then
                    local token, payload = jwt_auth.generate_token({
                        id = "user_001",
                        email = "[email protected]",
                        role = "admin",
                    })

                    ngx.say(cjson.encode({
                        access_token = token,
                        token_type = "Bearer",
                        expires_in = 7200,
                    }))
                else
                    ngx.status = 401
                    ngx.say(cjson.encode({error = "Invalid credentials"}))
                end
            }
        }

        # 受保护的 API
        location /api/users {
            proxy_pass http://user_backend;
        }
    }
}

7.3 API Key 认证

适合机器对机器(M2M)通信场景。

-- /usr/local/openresty/lua/auth/apikey_auth.lua
local _M = {}

local cjson = require "cjson"

-- API Key 存储(生产环境使用 Redis/数据库)
local api_keys = {
    ["ak_prod_1234567890"] = {
        name = "Production Service",
        rate_limit = 1000,
        permissions = {"read", "write"},
        enabled = true,
    },
    ["ak_test_0987654321"] = {
        name = "Test Service",
        rate_limit = 100,
        permissions = {"read"},
        enabled = true,
    },
}

-- 从请求中提取 API Key
local function extract_api_key()
    -- 方式 1:Header
    local key = ngx.req.get_headers()["X-API-Key"]
    if key then return key end

    -- 方式 2:查询参数
    key = ngx.req.get_uri_args().api_key
    if key then return key end

    -- 方式 3:Authorization (ApiKey scheme)
    local auth = ngx.req.get_headers()["Authorization"]
    if auth then
        key = auth:match("^ApiKey%s+(.+)$")
        if key then return key end
    end

    return nil
end

function _M.authenticate()
    local key = extract_api_key()

    if not key then
        ngx.status = 401
        ngx.header["Content-Type"] = "application/json"
        ngx.say(cjson.encode({
            error = "Unauthorized",
            message = "API key required. Send via X-API-Key header or api_key query parameter.",
        }))
        return false
    end

    -- 从缓存查找
    local cache = ngx.shared.gateway_config
    local cached = cache:get("apikey:" .. key)
    local key_info

    if cached then
        key_info = cjson.decode(cached)
    else
        key_info = api_keys[key]
        if key_info then
            cache:set("apikey:" .. key, cjson.encode(key_info), 300)
        end
    end

    if not key_info then
        ngx.status = 401
        ngx.say(cjson.encode({
            error = "Unauthorized",
            message = "Invalid API key",
        }))
        return false
    end

    if not key_info.enabled then
        ngx.status = 403
        ngx.say(cjson.encode({
            error = "Forbidden",
            message = "API key is disabled",
        }))
        return false
    end

    -- 设置请求上下文
    ngx.var.user_id = key_info.name
    ngx.var.user_role = "api"

    -- 传递给后端
    ngx.req.set_header("X-API-Key-Name", key_info.name)
    ngx.req.set_header("X-API-Key-Permissions", cjson.encode(key_info.permissions))

    return true
end

return _M

7.4 OAuth2 认证

OAuth2 用于第三方应用接入,常见授权模式:

模式适用场景安全性
Authorization CodeWeb 应用
Client Credentials服务间通信
ImplicitSPA(已不推荐)
Resource Owner Password受信任客户端

OAuth2 Token 验证

-- /usr/local/openresty/lua/auth/oauth2_auth.lua
local _M = {}

local http = require "resty.http"
local cjson = require "cjson"

local config = {
    -- OAuth2 Provider 信息
    introspect_url = "https://auth.example.com/oauth2/introspect",
    client_id = "gateway-client",
    client_secret = "gateway-secret",
    -- 本地缓存
    cache_ttl = 60,
}

-- Token 内省(Introspection)
local function introspect_token(token)
    local httpc = http.new()
    httpc:set_timeout(5000)

    local res, err = httpc:request_uri(config.introspect_url, {
        method = "POST",
        body = "token=" .. token .. "&token_type_hint=access_token",
        headers = {
            ["Content-Type"] = "application/x-www-form-urlencoded",
            ["Authorization"] = "Basic " .. ngx.encode_base64(
                config.client_id .. ":" .. config.client_secret
            ),
        },
    })

    if not res then
        return nil, "introspection request failed: " .. err
    end

    if res.status ~= 200 then
        return nil, "introspection returned status: " .. res.status
    end

    local result = cjson.decode(res.body)
    if not result.active then
        return nil, "token is not active"
    end

    return result
end

function _M.authenticate()
    local auth_header = ngx.req.get_headers()["Authorization"]
    if not auth_header then
        ngx.status = 401
        ngx.header["WWW-Authenticate"] = 'Bearer realm="api"'
        ngx.say(cjson.encode({error = "Bearer token required"}))
        return false
    end

    local token = auth_header:match("^Bearer%s+(.+)$")
    if not token then
        ngx.status = 401
        ngx.say(cjson.encode({error = "Invalid authorization format"}))
        return false
    end

    -- 检查缓存
    local cache = ngx.shared.jwt_cache
    local cached = cache:get("oauth2:" .. token)
    if cached then
        local info = cjson.decode(cached)
        ngx.var.user_id = info.sub or ""
        ngx.var.user_role = info.role or ""
        return true
    end

    -- 内省 Token
    local info, err = introspect_token(token)
    if not info then
        ngx.status = 401
        ngx.say(cjson.encode({error = "Invalid token", message = err}))
        return false
    end

    -- 缓存结果
    cache:set("oauth2:" .. token, cjson.encode(info), config.cache_ttl)

    -- 设置用户信息
    ngx.var.user_id = info.sub or info.client_id or ""
    ngx.var.user_role = info.role or ""
    ngx.req.set_header("X-User-ID", info.sub or "")
    ngx.req.set_header("X-Client-ID", info.client_id or "")
    ngx.req.set_header("X-Token-Scope", info.scope or "")

    return true
end

return _M

7.5 mTLS 双向认证

mTLS(Mutual TLS)用于高安全性场景,客户端和服务端互相验证证书。

server {
    listen 8443 ssl;

    # 服务端证书
    ssl_certificate     /etc/ssl/server.crt;
    ssl_certificate_key /etc/ssl/server.key;

    # 客户端证书验证
    ssl_client_certificate /etc/ssl/ca.crt;
    ssl_verify_client on;          # 强制要求客户端证书
    ssl_verify_depth 2;

    # CRL 检查(可选)
    ssl_crl /etc/ssl/ca.crl;

    location /api/ {
        content_by_lua_block {
            -- 获取客户端证书信息
            local client_cert = ngx.var.ssl_client_s_dn
            local client_serial = ngx.var.ssl_client_serial
            local client_verify = ngx.var.ssl_client_verify

            if client_verify ~= "SUCCESS" then
                ngx.status = 403
                ngx.say('{"error":"Client certificate verification failed"}')
                return
            end

            -- 提取证书中的 CN 作为客户端标识
            local client_cn = client_cert:match("CN=([^/,]+)")
            ngx.req.set_header("X-Client-CN", client_cn)
            ngx.req.set_header("X-Client-Serial", client_serial)

            ngx.say('{"client":"' .. client_cn .. '","verified":true}')
        }
    }
}

7.6 RBAC 权限模型

Role-Based Access Control(基于角色的访问控制)是最常用的鉴权模型。

-- /usr/local/openresty/lua/auth/rbac.lua
local _M = {}

-- 角色权限配置
local roles = {
    admin = {
        permissions = {"*"},  -- 所有权限
    },
    editor = {
        permissions = {
            "users:read",
            "users:write",
            "posts:read",
            "posts:write",
            "posts:delete",
        },
    },
    viewer = {
        permissions = {
            "users:read",
            "posts:read",
        },
    },
    api = {
        permissions = {
            "api:read",
            "api:write",
        },
    },
}

-- 资源-操作 映射
local resource_actions = {
    ["GET:/api/users"]     = "users:read",
    ["POST:/api/users"]    = "users:write",
    ["PUT:/api/users"]     = "users:write",
    ["DELETE:/api/users"]  = "users:delete",
    ["GET:/api/posts"]     = "posts:read",
    ["POST:/api/posts"]    = "posts:write",
    ["DELETE:/api/posts"]  = "posts:delete",
}

-- 检查权限
function _M.check_permission(user_role, method, uri)
    local role_config = roles[user_role]
    if not role_config then
        return false, "Unknown role: " .. user_role
    end

    -- 超级权限
    for _, perm in ipairs(role_config.permissions) do
        if perm == "*" then
            return true
        end
    end

    -- 构建所需权限
    local required = method .. ":" .. uri
    -- 尝试匹配资源级权限
    for pattern, permission in pairs(resource_actions) do
        local p_method, p_path = pattern:match("^([^:]+):(.+)$")
        if method == p_method and uri:match("^" .. p_path) then
            required = permission
            break
        end
    end

    -- 检查角色是否拥有权限
    for _, perm in ipairs(role_config.permissions) do
        if perm == required then
            return true
        end
    end

    return false, "Permission denied: " .. required
end

-- 中间件:鉴权检查
function _M.authorize()
    local user_role = ngx.var.user_role
    local method = ngx.req.get_method()
    local uri = ngx.var.uri

    -- 白名单路径跳过鉴权
    if uri:match("^/api/auth/") or uri == "/api/health" then
        return true
    end

    local ok, err = _M.check_permission(user_role, method, uri)
    if not ok then
        ngx.status = 403
        ngx.header["Content-Type"] = "application/json"
        ngx.say('{"error":"Forbidden","message":"' .. err .. '"}')
        return false
    end

    return true
end

return _M

nginx 配置:认证 + 鉴权

server {
    listen 8080;

    set $user_id "";
    set $user_role "";

    access_by_lua_block {
        -- 步骤 1:认证
        local jwt_auth = require "auth.jwt_auth"
        if not jwt_auth.authenticate() then
            return ngx.exit(ngx.HTTP_UNAUTHORIZED)
        end

        -- 步骤 2:鉴权
        local rbac = require "auth.rbac"
        if not rbac.authorize() then
            return ngx.exit(ngx.HTTP_FORBIDDEN)
        end
    }

    location /api/users {
        proxy_pass http://user_backend;
    }

    location /api/admin {
        # 需要 admin 角色
        access_by_lua_block {
            local jwt_auth = require "auth.jwt_auth"
            if not jwt_auth.authenticate() then
                return ngx.exit(ngx.HTTP_UNAUTHORIZED)
            end
            if ngx.var.user_role ~= "admin" then
                ngx.status = 403
                ngx.say('{"error":"Admin access required"}')
                return ngx.exit(ngx.HTTP_FORBIDDEN)
            end
        }
        proxy_pass http://admin_backend;
    }
}

7.7 多种认证方式并存

-- /usr/local/openresty/lua/auth/multi_auth.lua
local _M = {}

local auth_strategies = {
    {
        name = "jwt",
        detect = function()
            local auth = ngx.req.get_headers()["Authorization"]
            return auth and auth:match("^Bearer%s+")
        end,
        handler = function()
            local jwt = require "auth.jwt_auth"
            return jwt.authenticate()
        end,
    },
    {
        name = "api_key",
        detect = function()
            return ngx.req.get_headers()["X-API-Key"]
        end,
        handler = function()
            local apikey = require "auth.apikey_auth"
            return apikey.authenticate()
        end,
    },
    {
        name = "oauth2",
        detect = function()
            local auth = ngx.req.get_headers()["Authorization"]
            return auth and auth:match("^Bearer%s+") and true or false
        end,
        handler = function()
            local oauth2 = require "auth.oauth2_auth"
            return oauth2.authenticate()
        end,
    },
}

-- 尝试所有认证策略
function _M.authenticate()
    for _, strategy in ipairs(auth_strategies) do
        if strategy.detect() then
            ngx.log(ngx.INFO, "Auth strategy detected: ", strategy.name)
            return strategy.handler()
        end
    end

    -- 无认证信息
    ngx.status = 401
    ngx.header["WWW-Authenticate"] = 'Bearer realm="api", ApiKey realm="api"'
    ngx.say('{"error":"Authentication required"}')
    return false
end

return _M

7.8 注意事项

Token 安全:JWT Secret 必须足够长(256 位以上),不要硬编码在代码中,使用环境变量或密钥管理服务。

Token 刷新:Access Token 有效期不宜过长(建议 2 小时),配合 Refresh Token 实现无感刷新。

缓存一致性:Token 验证结果缓存需要考虑 Token 吊销场景,使用短 TTL 或黑名单机制。

mTLS 性能:TLS 握手开销较大,适合内部服务间通信,不适合面向公网的 API。


上一章← 第 06 章 - 限流与流控 下一章第 08 章 - 反向代理与负载均衡 →