在异构微服务架构中为Kong构建有状态认证授权插件


我们面临一个典型的技术演进困境。一个稳定运行多年的Ruby on Rails单体应用承载着核心业务逻辑,包括一套复杂且深度耦合的用户认证与授权体系。为了应对新的性能挑战和业务敏捷性需求,技术团队决定引入新的微服务,技术栈选型非常多样化:一个面向外部高并发场景的推送服务选用了基于Rust的Axum;一个内部数据处理服务采用了基于JVM的Quarkus以利用其丰富的Java生态和原生编译能力;还有一个轻量级的BFF层则选择了Node.js的Fastify。

所有外部流量都通过Kong API Gateway进行路由。最初的方案简单粗暴:

-- kong.conf snippet
services:
- name: legacy-rails-service
  url: http://rails-app:3000
- name: new-axum-service
  url: http://axum-app:8000

routes:
- name: rails-route
  service: legacy-rails-service
  paths:
  - /rails
- name: axum-route
  service: new-axum-service
  paths:
  - /axum-push

这种天真的代理模式直接将认证授权的责任推给了下游的各个服务。很快,问题就暴露了:难道要让Axum, Quarkus, Fastify的开发团队各自去实现一套逻辑,调用Rails的认证接口来验证用户身份吗?这不仅是重复劳动,更是将脆弱的耦合关系扩散到了整个系统,任何Rails认证逻辑的变更都将是一场灾难。

定义问题:统一认证与无缝迁移

架构决策的核心目标变得清晰:我们需要一个统一的认证授权层,它必须在API网关层面执行,对下游微服务透明。

具体要求如下:

  1. 集中处理: 所有进入微服务集群的请求,必须在Kong层面完成认证和授权检查。
  2. 兼容旧系统: 认证逻辑必须复用现有的Rails应用的会话或Token验证机制,避免在迁移初期重写核心认证模块。
  3. 高性能: 对于新建的高性能服务(尤其是Axum和Fastify),认证层引入的延迟必须尽可能低。
  4. 对下游透明: 认证成功后,下游服务应能以一种标准化的方式(例如,通过HTTP Header)获取到用户信息,而无需关心认证过程的细节。
  5. 高可用: Rails单体应用的任何抖动或临时不可用,不应导致整个认证系统瘫痪,尤其是不应影响已经建立会话的用户的访问。

方案A:各服务中间件自行校验

这是最容易想到的方案。每个微服务都在自己的框架内实现一个认证中间件。

例如,在Fastify服务中,代码可能如下:

// fastify-auth-middleware.js
const axios = require('axios');

const RAILS_AUTH_ENDPOINT = 'http://rails-app:3000/internal/auth/verify';

async function authMiddleware(request, reply) {
    const authToken = request.headers['authorization'];
    if (!authToken) {
        reply.code(401).send({ error: 'Missing authorization token' });
        return;
    }

    try {
        // 对每个请求都发起一次到Rails的验证调用
        const response = await axios.post(RAILS_AUTH_ENDPOINT, {}, {
            headers: { 'Authorization': authToken }
        });

        if (response.status === 200 && response.data.userId) {
            // 验证通过,将用户信息注入请求
            request.user = { id: response.data.userId, roles: response.data.roles };
        } else {
            reply.code(401).send({ error: 'Invalid token' });
        }
    } catch (error) {
        // Rails服务不可用或验证失败
        console.error('Auth verification failed:', error.message);
        reply.code(503).send({ error: 'Auth service unavailable' });
    }
}

// 在应用中注册
// server.addHook('preHandler', authMiddleware);

优点:

  • 实现直观,每个团队可以在自己熟悉的技术栈内完成工作。

缺点:

  • 代码冗余: 每个服务都需要实现几乎相同的逻辑。
  • 紧密耦合: 所有新服务都直接依赖于Rails的内部接口,形成了一个难以管理的依赖网络。
  • 性能瓶颈: 每个外部请求都会在内部触发一次额外的网络调用(Service -> Rails),在高并发下,这会急剧增加响应延迟并给Rails应用带来巨大压力。
  • 策略不一致: 缓存、重试、超时等策略难以在不同语言和框架间保持统一,最终导致系统行为不一致。

这个方案在真实项目中是不可接受的,它将问题分解,但代价是复杂度的扩散。

方案B:独立的认证微服务

第二个方案是构建一个全新的、专门负责认证的微服务。这个服务可以封装对Rails认证逻辑的调用。

graph TD
    subgraph "请求流程"
        Client -->|Request with Token| Kong
        Kong -->|Forward| Auth_Service
        Auth_Service -->|Verify Token| Rails_App
        Rails_App -->|Validation Result| Auth_Service
        Auth_Service -->|User Info or 401| Kong
        Kong -->|Proxy with User Header| Upstream_Service(Axum/Quarkus/Fastify)
    end

优点:

  • 职责分离: 认证逻辑被清晰地剥离出来,符合微服务单一职责原则。
  • 解耦: 上游服务不再直接依赖Rails,而是依赖于定义良好的认证服务接口。

缺点:

  • 运维复杂性: 引入了一个新的、高关键性的服务需要维护,其自身的高可用性成为整个系统的命门。
  • 性能问题未根本解决: 依然存在额外的网络跳数 (Client -> Kong -> Auth Service -> Upstream)。虽然比方案A少了一次到Rails的直接调用(可以在Auth Service内部缓存),但请求链路依然被拉长了。
  • 迁移阵痛: 构建这个服务本身就需要投入开发资源,并且它依然需要一种方式(例如共享数据库或调用内部API)来与Rails的会话存储进行交互,这本身就是一个棘手的技术问题。

最终选择:基于Kong的自定义有状态插件

我们最终选择了在问题的源头——API网关——上解决它。通过为Kong编写一个自定义Lua插件,我们可以在流量入口处完成所有认证逻辑。

决策理由:

  1. 性能最优: 插件代码运行在Kong的Nginx工作进程中,使用LuaJIT执行,性能极高。它可以通过ngx.location.capture等内部API与上游服务通信,这比完整的HTTP客户端调用要轻量得多。
  2. 中心化控制: 所有认证逻辑、缓存策略、超时设置都集中在一个地方,易于管理和审计。
  3. 对下游完全透明: 微服务开发者完全不需要关心认证细节,他们只需要信任Kong注入的X-User-ID等头信息即可。
  4. 韧性设计: 插件内部可以实现强大的缓存机制。即使Rails认证服务暂时不可用,只要用户的认证信息在缓存中,请求依然可以成功处理,极大地提高了系统的韧性。

核心实现:kong-legacy-auth 插件

我们将插件命名为 kong-legacy-auth。一个Kong插件通常由 handler.lua (核心逻辑) 和 schema.lua (配置定义) 两个文件组成。

1. schema.lua:定义插件配置

这个文件定义了插件的可配置项,让运维人员可以在启用插件时动态传入参数。

-- kong/plugins/kong-legacy-auth/schema.lua

-- 详尽的注释是生产级代码的标志
return {
  no_consumer = true, -- 这个插件是全局的,不绑定到特定的Consumer
  fields = {
    auth_url = {
      type = "string",
      required = true,
      description = "用于验证Token或Session的内部Rails端点URL."
    },
    auth_method = {
      type = "string",
      default = "POST",
      one_of = {"GET", "POST"},
      description = "调用验证端点的HTTP方法."
    },
    timeout = {
      type = "number",
      default = 2000, -- 默认2秒超时
      description = "调用验证端点的超时时间 (ms)."
    },
    cache_ttl = {
      type = "number",
      default = 300, -- 默认缓存5分钟
      description = "认证成功结果的缓存时间 (seconds). 0表示禁用缓存."
    },
    user_id_header = {
      type = "string",
      default = "X-User-ID",
      description = "注入到下游请求的表示用户ID的Header名称."
    },
    user_roles_header = {
      type = "string",
      default = "X-User-Roles",
      description = "注入到下游请求的表示用户角色的Header名称 (逗号分隔)."
    }
  }
}

这份Schema清晰地定义了插件的行为,具备良好的可维护性。

2. handler.lua:核心认证逻辑

这是插件的心脏,所有的处理逻辑都在这里。我们主要利用access阶段,它在Kong将请求代理到上游服务之前执行。

-- kong/plugins/kong-legacy-auth/handler.lua

local BasePlugin = require "kong.plugins.base_plugin"
local http = require "resty.http"
local cjson = require "cjson.safe"

local LegacyAuthHandler = BasePlugin:extend()

LegacyAuthHandler.PRIORITY = 1000 -- 确保在其他插件之前执行
LegacyAuthHandler.VERSION = "1.0.0"

function LegacyAuthHandler:new()
  LegacyAuthHandler.super.new(self, "kong-legacy-auth")
end

-- 核心的 access 阶段处理函数
function LegacyAuthHandler:access(conf)
  LegacyAuthHandler.super.access(self)

  local authorization_header = kong.request.get_header("Authorization")
  if not authorization_header then
    return kong.response.exit(401, { message = "Authorization header is missing" })
  end

  -- 1. 缓存优先策略
  -- 这里的坑在于:缓存键必须是唯一的且无歧义的。直接使用token作为键。
  local cache_key = "legacy_auth:" .. authorization_header
  if conf.cache_ttl > 0 then
    local cached_user_data_str = kong.cache:get(cache_key)
    if cached_user_data_str then
      local user_data, err = cjson.decode(cached_user_data_str)
      if not err and user_data then
        kong.log.debug("Auth cache hit for key: ", cache_key)
        -- 从缓存中获取并设置headers
        kong.service.request.set_header(conf.user_id_header, user_data.id)
        if user_data.roles then
          kong.service.request.set_header(conf.user_roles_header, table.concat(user_data.roles, ","))
        end
        return -- 认证通过,直接返回
      end
    end
  end

  -- 2. 缓存未命中,执行实时验证
  kong.log.debug("Auth cache miss, proceeding with live validation for key: ", cache_key)
  
  -- 使用resty.http进行网络调用,这是OpenResty的标准做法
  local httpc, err = http.new()
  if not httpc then
    kong.log.err("failed to create http client: ", err)
    return kong.response.exit(500, { message = "Internal Server Error: Cannot create HTTP client" })
  end

  httpc:set_timeout(conf.timeout)

  -- 构造到Rails验证服务的请求
  local res, err = httpc:request_uri(conf.auth_url, {
    method = conf.auth_method,
    headers = {
      ["Authorization"] = authorization_header,
      ["Content-Type"] = "application/json"
    }
  })

  -- 3. 健壮的错误处理
  if not res then
    kong.log.err("failed to request auth service: ", err)
    -- 在真实项目中,这里应该有更精细的错误分类,例如超时、连接拒绝等
    return kong.response.exit(503, { message = "Authentication service unavailable" })
  end

  -- 关闭连接以释放资源
  httpc:close()

  -- 4. 处理验证结果
  if res.status == 200 then
    local body_str = res.body
    local body, json_err = cjson.decode(body_str)

    if json_err or not body.user_id then
      kong.log.err("failed to decode auth response or missing user_id: ", json_err or "nil")
      return kong.response.exit(500, { message = "Invalid response from authentication service" })
    end

    -- 认证成功,设置headers
    kong.service.request.set_header(conf.user_id_header, body.user_id)
    
    local roles_str = ""
    if body.roles and type(body.roles) == "table" then
      roles_str = table.concat(body.roles, ",")
      kong.service.request.set_header(conf.user_roles_header, roles_str)
    end

    -- 5. 将成功结果写入缓存
    if conf.cache_ttl > 0 then
      local user_data_to_cache = {
        id = body.user_id,
        roles = body.roles or {}
      }
      local cache_val, cache_err = cjson.encode(user_data_to_cache)
      if not cache_err then
        local ok, err = kong.cache:set(cache_key, cache_val, conf.cache_ttl)
        if not ok then
            kong.log.warn("failed to set auth cache: ", err)
        end
      end
    end

  elseif res.status == 401 or res.status == 403 then
    -- 明确的认证/授权失败
    return kong.response.exit(res.status, { message = "Invalid credentials" })
  else
    -- 其他未知错误
    kong.log.warn("auth service returned unexpected status: ", res.status)
    return kong.response.exit(502, { message = "Bad response from authentication service" })
  end
end

return LegacyAuthHandler

下游服务的简化

有了这个插件,下游微服务的代码变得极其简单。它们不再需要关心认证的复杂过程,只需信任网关传递过来的用户信息。

以Axum服务为例,获取用户ID的代码如下:

// axum-service/src/main.rs
use axum::{
    async_trait,
    extract::{FromRequestParts, State},
    http::{request::Parts, HeaderMap, StatusCode},
    response::{IntoResponse, Response},
    routing::get,
    Router,
};
use std::net::SocketAddr;

// 定义一个Extractor来安全地提取用户信息
struct AuthenticatedUser {
    id: i64,
    roles: Vec<String>,
}

#[async_trait]
impl<S> FromRequestParts<S> for AuthenticatedUser
where
    S: Send + Sync,
{
    type Rejection = (StatusCode, &'static str);

    async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
        let headers = &parts.headers;

        // 从Kong插件设置的Header中提取用户ID
        let user_id_str = headers
            .get("X-User-ID")
            .and_then(|value| value.to_str().ok())
            .ok_or((StatusCode::UNAUTHORIZED, "Missing or invalid X-User-ID header"))?;
        
        let id = user_id_str.parse::<i64>().map_err(|_| {
            (StatusCode::UNAUTHORIZED, "Invalid user ID format")
        })?;

        // 提取角色信息
        let roles = headers
            .get("X-User-Roles")
            .and_then(|value| value.to_str().ok())
            .map(|s| s.split(',').map(String::from).collect())
            .unwrap_or_else(Vec::new);

        Ok(AuthenticatedUser { id, roles })
    }
}

// 业务Handler
async fn protected_route(user: AuthenticatedUser) -> Response {
    // 业务逻辑可以直接使用 user.id 和 user.roles
    // 例如:检查用户是否有权限执行此操作
    if !user.roles.contains(&"admin".to_string()) {
        return (StatusCode::FORBIDDEN, "Admin role required").into_response();
    }
    format!("Hello, admin user with ID: {}!", user.id).into_response()
}

// ... main function to start the server

这种模式极大地降低了微服务开发的认知负担。

架构的局限性与未来演进

这个基于Kong自定义插件的方案,虽然优雅地解决了当前异构环境下的统一认证问题,但它并非银弹。

首先,Rails单体应用作为认证信息的唯一权威来源,其本身依然是系统的一个潜在瓶颈和故障点。我们的缓存策略(cache_ttl)有效缓解了这个问题,将对Rails的强依赖转变为弱依赖,但这是一种“容错”而非“根治”。当大量新用户同时认证或缓存集中失效时,Rails应用的压力仍然存在。

其次,将认证逻辑用Lua编写并部署在Kong中,引入了一定的技术栈复杂性。团队需要有能力开发、测试和维护Lua代码。复杂的业务授权逻辑(例如基于属性的访问控制 ABAC)如果全部实现在插件中,会让插件变得臃肿且难以调试。

长远的演进方向,应该是将Rails中的认证和用户模型逐步剥离出来,形成一个完全独立的、高可用的身份服务(Identity Provider)。届时,kong-legacy-auth插件的使命便告一段落,可以被标准的OIDC或JWT插件所取代。但在此之前,作为一个务实的、高性价比的过渡性架构,它为团队争取了宝贵的重构时间窗口,并保障了新业务的快速迭代。


  目录