基于 Kotlin 与 Docker Swarm 构建多租户 SAML 到 JWT 联邦认证网关


企业客户的集成需求往往会带来非标准的技术挑战。一个常见的场景是,我们内部的微服务体系普遍采用基于 JWT 的无状态认证,但大型企业客户坚持使用其内部的 SAML 2.0 IdP (Identity Provider) 进行单点登录(SSO)。为每个客户单独硬编码一套 SAML 对接逻辑不仅效率低下,而且会迅速演变成一场维护噩梦。我们需要的是一个集中式的、支持多租户的联邦认证网关,它能动态地为不同租户处理 SAML 认证流程,并将其无缝转换为我们内部服务能够理解的 JWT。

问题的核心是:如何构建一个轻量、高可用且配置灵活的解决方案?

方案权衡:成品身份认证服务器 vs. 自研轻量级服务

在着手构建之前,必须对可选路径进行审慎评估。

方案A:部署现成的身份认证服务器 (如 Keycloak)

这是一个显而易见的选项。Keycloak 这样的开源项目功能强大,原生支持作为 SAML SP (Service Provider) 与上游 IdP 对接,同时也能作为 OAuth2/OIDC 服务器签发 JWT。

  • 优势:

    • 功能完备,覆盖了绝大多数身份认证场景。
    • 社区成熟,文档丰富。
    • 避免了重复造轮子,尤其是处理 SAML 这种基于 XML 签名的复杂协议。
  • 劣势:

    • 资源开销: Keycloak 是一个庞大的 Java 应用,其资源消耗对于一个仅需要协议转换的场景来说过于沉重。在资源相对受限的 Docker Swarm 环境中,这会成为一个显著的负担。
    • 配置复杂性: 为实现多租户 SAML 对接,需要在 Keycloak 中为每个租户创建独立的 Realm 或 Identity Provider 配置。这个过程通常依赖于其管理后台 UI 或繁琐的管理 API,自动化程度不高。
    • 定制化困难: 如果需要对认证流程或签发的 JWT 声明 (claims) 进行深度定制,例如根据租户信息动态添加特定业务字段,在 Keycloak 中实现起来会非常复杂,甚至需要开发自定义的 SPI (Service Provider Interface)。
    • 运维负担: 维护一个功能复杂的中间件,意味着需要理解其内部机制、集群模式和数据备份策略,这本身就是一个不小的运维挑战。

方案B:使用 Kotlin 构建一个专用的轻量级认证网关

这条路径主张“自己动手,丰衣足食”。我们利用现代化的技术栈(如 Kotlin + Ktor/Spring Boot)构建一个只做一件事并把它做好的微服务。

  • 优势:

    • 极致轻量: 服务只包含必要的逻辑,资源占用极低,可以以极小的成本在 Docker Swarm 中水平扩展多个副本。
    • 完全控制: 我们可以精确控制认证流程的每一步。JWT 的签发逻辑、声明内容、有效期等都可以根据数据库中的租户配置动态生成,灵活性极高。
    • 配置即数据: 多租户的配置信息(如 IdP 的元数据 URL、证书、实体 ID 等)可以作为普通业务数据存储在数据库中,通过简单的 CRUD 接口即可管理,极易实现自动化。
    • 技术栈统一: 与现有 Kotlin 微服务体系保持一致,降低了开发和维护的认知成本。
  • 劣势:

    • 开发成本: 需要投入初期开发时间。
    • 安全责任: SAML 协议的实现细节,尤其是 XML 签名的验证,必须严肃对待。如果处理不当,会引入严重的安全漏洞。幸运的是,有成熟的、经过安全审计的库可以依赖。

决策:选择方案B

在我们的场景下,灵活性和轻量化是压倒性的优势。我们不需要一个全功能的身份认证平台,只需要一个高效的“翻译官”。方案 B 允许我们构建一个与环境完美契合的组件,避免了方案 A 带来的资源浪费和运维复杂性。我们将承担起安全实现的责任,并通过选用高质量的第三方库来管理风险。

核心实现概览

我们将构建一个 Kotlin 服务,它作为所有租户的统一 SAML SP。其核心职责是:根据请求中的租户标识,从数据库加载对应的 IdP 配置,处理 SAML 登录流程,验证 SAML 断言 (Assertion),最后签发一个包含标准化身份信息的内部 JWT。

架构流程

整个认证流程的可视化表示如下:

sequenceDiagram
    participant User as 用户
    participant App as 业务应用
    participant AuthGateway as 认证网关 (Kotlin)
    participant Database as 配置数据库
    participant EnterpriseIdP as 企业 IdP

    User->>App: 访问受保护资源
    App->>User: 重定向至认证网关 (携带 tenant_id)
    User->>AuthGateway: 发起登录请求 /sso?tenant_id=...
    AuthGateway->>Database: 根据 tenant_id 查询 IdP 配置
    Database-->>AuthGateway: 返回 IdP 的 SSO URL, 证书等
    AuthGateway->>User: 构建 SAML AuthnRequest 并重定向至企业 IdP
    User->>EnterpriseIdP: 提交认证凭据 (用户名/密码)
    EnterpriseIdP-->>User: 认证成功, 返回包含 SAMLResponse 的表单
    User->>AuthGateway: 自动提交表单至 /acs (Assertion Consumer Service)
    
    Note right of AuthGateway: 核心安全步骤
    AuthGateway->>AuthGateway: 1. 使用 IdP 公钥验证 SAMLResponse 签名
    AuthGateway->>AuthGateway: 2. 解密断言 (如果加密)
    AuthGateway->>AuthGateway: 3. 提取用户属性 (email, name, groups)
    AuthGateway->>AuthGateway: 4. 使用网关自身私钥签发 JWT

    AuthGateway->>User: 重定向回业务应用, JWT 作为 URL 参数或 Cookie
    User->>App: 携带 JWT 访问
    App->>App: 验证 JWT 签名和声明
    App-->>User: 返回受保护资源

数据库模型

多租户配置的核心是数据库。一个简洁的表结构就足够了。我们使用 PostgreSQL 作为示例。

-- DDL for Tenant SAML Configuration
CREATE TABLE saml_tenant_config (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    tenant_id VARCHAR(100) UNIQUE NOT NULL,
    idp_entity_id VARCHAR(255) NOT NULL,            -- IdP 的实体 ID
    idp_sso_url VARCHAR(512) NOT NULL,              -- IdP 的单点登录 URL
    idp_x509_cert TEXT NOT NULL,                    -- IdP 的签名证书 (PEM 格式)
    sp_entity_id VARCHAR(255) NOT NULL,             -- 我们服务(SP)的实体 ID, 通常对所有租户一致
    sp_acs_url VARCHAR(512) NOT NULL,               -- 我们服务的 ACS URL
    name_id_format VARCHAR(255) DEFAULT 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress',
    is_active BOOLEAN NOT NULL DEFAULT true,
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

-- Index for fast lookup
CREATE INDEX idx_saml_tenant_config_tenant_id ON saml_tenant_config(tenant_id);

COMMENT ON COLUMN saml_tenant_config.idp_x509_cert IS 'Base64 encoded X.509 public certificate from the IdP for signature validation.';

这个模型允许我们通过一个简单的 tenant_id 来驱动整个 SAML 认证流程,实现了配置的动态化和隔离。

Kotlin 服务实现关键代码

我们使用 Ktor 框架,因为它轻量且适合构建此类网络服务。对于 SAML 协议处理,我们选用 com.onelogin:java-saml-core 库。对于 JWT 签发,选用 com.auth0:java-jwt

1. 依赖配置 (build.gradle.kts)

dependencies {
    // Ktor
    implementation("io.ktor:ktor-server-core:$ktorVersion")
    implementation("io.ktor:ktor-server-netty:$ktorVersion")

    // SAML Library
    implementation("com.onelogin:java-saml-core:2.8.0")

    // JWT Library
    implementation("com.auth0:java-jwt:4.3.0")
    
    // Database access (e.g., Exposed)
    implementation("org.jetbrains.exposed:exposed-core:$exposedVersion")
    implementation("org.jetbrains.exposed:exposed-jdbc:$exposedVersion")
    implementation("org.postgresql:postgresql:42.6.0")

    // Logging
    implementation("ch.qos.logback:logback-classic:$logbackVersion")
}

2. 动态 SAML 配置服务

这个服务负责从数据库加载配置,并为 java-saml 库动态构建其所需的 SettingsBuilder

// SamlConfigService.kt
import com.onelogin.saml2.settings.Saml2Settings
import com.onelogin.saml2.settings.SettingsBuilder
import java.util.Properties

// 假设 TenantConfig 是从数据库映射的实体类
data class TenantConfig(
    val tenantId: String,
    val idpEntityId: String,
    val idpSsoUrl: String,
    val idpX509Cert: String,
    val spEntityId: String,
    val spAcsUrl: String,
    val nameIdFormat: String
)

class SamlConfigService(private val db: Database) { // 注入数据库连接

    // 缓存可以加在这里以提高性能
    private val configCache = mutableMapOf<String, Saml2Settings>()

    /**
     * 根据租户ID获取SAML配置。这是整个多租户机制的核心。
     * @param tenantId 租户的唯一标识符
     * @return 构建好的 Saml2Settings 对象
     * @throws TenantNotFoundException 如果租户配置不存在或未激活
     */
    fun getSettingsForTenant(tenantId: String): Saml2Settings {
        // 在生产环境中,这里应该有缓存逻辑
        if (configCache.containsKey(tenantId)) {
            return configCache.getValue(tenantId)
        }

        // 从数据库查询配置
        val config = findTenantConfigInDb(tenantId) 
            ?: throw TenantNotFoundException("Configuration for tenant '$tenantId' not found.")

        val props = Properties().apply {
            // SP (我们服务) 的信息
            // SP私钥和证书应通过Docker Swarm Secrets安全地加载
            this["onelogin.saml2.sp.entityid"] = config.spEntityId
            this["onelogin.saml2.sp.assertion_consumer_service.url"] = config.spAcsUrl
            this["onelogin.saml2.sp.assertion_consumer_service.binding"] = "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"
            this["onelogin.saml2.sp.x509cert"] = loadSpCertificate() // 从安全位置加载
            this["onelogin.saml2.sp.privatekey"] = loadSpPrivateKey() // 从安全位置加载

            // IdP (企业客户) 的信息
            this["onelogin.saml2.idp.entityid"] = config.idpEntityId
            this["onelogin.saml2.idp.single_sign_on_service.url"] = config.idpSsoUrl
            this["onelogin.saml2.idp.x509cert"] = config.idpX509Cert

            // 安全设置
            this["onelogin.saml2.security.nameid_encrypted"] = "false"
            this["onelogin.saml2.security.authnrequest_signed"] = "true" // 推荐签名请求
            this["onelogin.saml2.security.want_assertions_signed"] = "true"
            this["onelogin.saml2.security.signature_algorithm"] = "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256"
        }

        val settings = SettingsBuilder().fromProperties(props).build()
        configCache[tenantId] = settings // 存入缓存
        return settings
    }
    
    // ... 数据库查询 (findTenantConfigInDb) 和密钥加载 (loadSp*) 的具体实现
}

3. Ktor 路由和处理器

// Application.kt
import com.onelogin.saml2.Auth
import io.ktor.server.application.*
import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import com.auth0.jwt.JWT
import com.auth0.jwt.algorithms.Algorithm
import java.util.Date

fun Application.main() {
    val samlConfigService = SamlConfigService(database)
    val jwtAlgorithm = Algorithm.RSA256(loadJwtPublicKey(), loadJwtPrivateKey()) // 同样使用安全方式加载
    val jwtIssuer = "my-auth-gateway"

    routing {
        // 1. 登录入口点 (SP-Initiated SSO)
        get("/sso") {
            val tenantId = call.request.queryParameters["tenant_id"]
            if (tenantId.isNullOrBlank()) {
                call.respondText("Missing tenant_id", status = HttpStatusCode.BadRequest)
                return@get
            }
            try {
                val settings = samlConfigService.getSettingsForTenant(tenantId)
                val auth = Auth(settings, call.request.toHttpServletRequest(), call.response.toHttpServletResponse())
                // 重定向到IdP
                auth.login()
            } catch (e: TenantNotFoundException) {
                call.respondText(e.message ?: "Tenant not found", status = HttpStatusCode.NotFound)
            } catch (e: Exception) {
                // 记录详细错误日志
                log.error("SAML login initiation failed for tenant $tenantId", e)
                call.respondText("Internal server error", status = HttpStatusCode.InternalServerError)
            }
        }

        // 2. ACS 端点 (Assertion Consumer Service)
        post("/acs") {
            // Ktor没有直接的租户标识,但SAML库可以通过state参数或Issuer来恢复上下文
            // 一个简单的做法是让 ACS URL 包含租户ID,如 /acs/{tenantId}
            val tenantId = call.parameters["tenantId"] ?: throw IllegalArgumentException("Missing tenantId in ACS path")
            
            try {
                val settings = samlConfigService.getSettingsForTenant(tenantId)
                val auth = Auth(settings, call.request.toHttpServletRequest(), call.response.toHttpServletResponse())
                
                // 处理 SAMLResponse
                auth.processResponse()

                if (!auth.isAuthenticated) {
                    call.respondText("SAML authentication failed. Errors: ${auth.errors}", status = HttpStatusCode.Unauthorized)
                    return@post
                }
                
                val attributes = auth.attributes
                val userEmail = auth.nameId
                
                // 签发内部 JWT
                val token = JWT.create()
                    .withIssuer(jwtIssuer)
                    .withSubject(userEmail)
                    .withClaim("tenant_id", tenantId)
                    .withClaim("full_name", attributes["firstName"]?.firstOrNull() + " " + attributes["lastName"]?.firstOrNull())
                    .withClaim("groups", attributes["memberOf"] ?: emptyList()) // 传递用户组信息
                    .withExpiresAt(Date(System.currentTimeMillis() + 3_600_000)) // 1小时有效期
                    .sign(jwtAlgorithm)

                // 认证成功, 重定向到前端应用,并带上token
                val redirectUrl = "https://app.example.com/login/callback?token=$token"
                call.respondRedirect(redirectUrl)

            } catch (e: Exception) {
                log.error("SAML ACS processing failed for tenant $tenantId", e)
                call.respondText("Invalid SAML response", status = HttpStatusCode.BadRequest)
            }
        }
    }
}

一个常见的错误是:在处理 ACS 请求时,忘记对 SAML 响应进行严格的验证。auth.processResponse() 内部封装了所有必要的安全检查,包括时间戳验证(防止重放攻击)、签名验证和目标 URL 验证。任何一步失败,auth.isAuthenticated 都会是 false。必须检查这个状态。

Docker Swarm 部署

为了实现高可用,我们将在 Docker Swarm 中部署至少两个服务副本。关键的安全凭据(如 SP 私钥、JWT 签名私钥)将通过 Docker Secrets 进行管理。

docker-compose.yml

version: '3.8'

services:
  auth-gateway:
    image: my-registry/auth-gateway:1.0.0
    ports:
      - "8080:8080"
    environment:
      - DB_URL=jdbc:postgresql://db:5432/authdb
      - DB_USER=admin
      - DB_PASSWORD_FILE=/run/secrets/db_password # 从secret读取
    secrets:
      - db_password
      - sp_private_key
      - sp_certificate
      - jwt_private_key
    deploy:
      replicas: 3
      restart_policy:
        condition: on-failure
      placement:
        constraints:
          - node.role == worker
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
      interval: 30s
      timeout: 10s
      retries: 3

  db:
    image: postgres:14-alpine
    volumes:
      - db_data:/var/lib/postgresql/data
    environment:
      - POSTGRES_DB=authdb
      - POSTGRES_USER=admin
      - POSTGRES_PASSWORD_FILE=/run/secrets/db_password
    secrets:
      - db_password
    deploy:
      replicas: 1
      placement:
        constraints:
          - node.role == manager # 建议将数据库放在特定节点

volumes:
  db_data:

secrets:
  db_password:
    file: ./secrets/db_password.txt
  sp_private_key:
    file: ./secrets/sp.key
  sp_certificate:
    file: ./secrets/sp.crt
  jwt_private_key:
    file: ./secrets/jwt.key

在 Kotlin 应用中,读取 secret 的代码很简单:

import java.io.File

fun loadSpPrivateKey(): String {
    // Docker Swarm 会将 secret 文件挂载到 /run/secrets/ 目录下
    val secretFile = File("/run/secrets/sp_private_key")
    if (!secretFile.exists()) {
        // 本地开发回退逻辑
        return ClassLoader.getSystemResource("dev-sp.key").readText()
    }
    return secretFile.readText()
}

这里的坑在于:必须确保容器内的应用有权限读取 /run/secrets 目录下的文件。同时,要为本地开发环境提供回退机制,否则开发流程会变得非常麻烦。

架构的扩展性与局限性

这个自研的轻量级网关虽然解决了核心问题,但了解其边界同样重要。

扩展性:

  • 支持 OIDC: 可以在此基础上扩展,增加 OIDC Provider 的功能,为现代应用提供服务。
  • 角色映射: 可以在 JWT 签发阶段,根据从 SAML 断言中获取的用户组 (groups),查询数据库中的角色映射表,将外部角色转换为内部系统的权限标识,并写入 JWT 的 scoperoles 声明中。
  • 动态元数据: 可以增加一个端点 /metadata/{tenantId},动态生成每个租户的 SP 元数据 XML 文件,简化客户 IdP 的配置过程。

局限性:

  • 用户生命周期管理 (SCIM): 本方案只处理认证。用户的创建、更新、禁用等生命周期管理需要通过 SCIM (System for Cross-domain Identity Management) 协议实现,这需要额外的开发工作。
  • 单点登出 (SLO): 实现 SAML 的 Single Logout 流程比 SSO 复杂得多,需要协调所有参与方的会话状态。当前方案没有实现 SLO,用户登出只能依赖于客户端删除 JWT 和 IdP 侧的会话过期。在多数场景下,这已经足够。
  • 数据库依赖: 租户配置存储在数据库中,虽然实现了动态性,但也引入了对数据库的运行时依赖。数据库的高可用性变得至关重要。若数据库宕机,所有租户的登录流程都会中断。对配置的内存缓存和合理的 TTL (Time-To-Live) 策略可以在一定程度上缓解此问题。
  • Docker Swarm 的局限: 虽然 Swarm 简单易用,但它在网络策略、存储编排和生态系统方面不如 Kubernetes 强大。如果未来系统需要服务网格 (Service Mesh) 或更复杂的部署策略,可能需要考虑向 Kubernetes 迁移。但对于当前的目标,Swarm 是一个成本效益极高的选择。

  目录