构建基于 Haskell 的 SAML 断言验证中间件以支撑分布式微前端架构


随着内部平台工具集的快速膨胀,我们面临一个棘手的架构问题:如何为数十个独立部署、技术栈各异的微前端应用提供一个统一、可信且易于维护的单点登录(SSO)方案。这些应用服务于不同的业务线,但都需要接入公司统一的身份提供商(IdP),该 IdP 使用 SAML 2.0 协议。

最初的方案是让每个微前端团队自行集成 SAML 库。这很快导致了灾难:实现质量参差不齐,安全漏洞频发(特别是 XML 签名验证部分),并且每次 IdP 证书轮换都意味着所有团队需要同步更新和部署,协调成本极高。

我们需要一个中央化的解决方案,一个能在流量入口处终结 SAML 认证、并将身份信息以一种轻量、安全的方式注入下游服务的组件。

方案 A:API 网关内置 SAML 插件

这是最直接的思路。我们使用的开源 API 网关提供了商业或社区的 SAML 插件。

  • 优势:

    • 快速集成,开箱即用。
    • 无需编写认证代码,专注于业务。
    • 由社区或厂商维护,理论上能跟上协议标准。
  • 劣势:

    • 黑盒实现: 我们无法完全审计其 XML 解析和密码学操作的安全性。在真实项目中,SAML 的 XML 包装攻击(XML Signature Wrapping)是一个常见且隐蔽的威胁,对一个我们无法完全控制的黑盒插件,我们始终抱有疑虑。
    • 扩展性受限: 公司的身份系统有复杂的属性(Attribute)转换和部门角色映射逻辑。插件通常只提供基础的断言验证,定制这些逻辑要么不可能,要么需要编写复杂的 Lua 脚本,这又引入了新的维护和测试难题。
    • 性能与资源: 商业插件通常基于 Java 或其他 JVM 语言,内存占用较大。在我们的高并发网关上挂载这样一个重型插件,对整体性能和延迟的影响是需要严肃评估的。

方案 B:自研 Haskell 服务作为认证中间件

这个方案更加激进:开发一个独立的、轻量级的微服务,专门负责 SAML 断言验证,并将其作为前置中间件集成到我们的 API 网关路由中。我们选择 Haskell 作为实现语言。

  • 优势:

    • 极致的安全性与正确性: Haskell 强大的静态类型系统是处理复杂、非结构化数据(如 XML)的利器。我们可以定义精确的代数数据类型(ADT)来映射 SAML 的 XML 结构,利用 xml-conduit 这样的库进行流式、安全的解析。任何不符合预期的结构都会在编译期或解析时被拒绝,而不是在运行时引发空指针或类型转换异常。这从根本上消除了大量潜在的 XML 解析漏洞。
    • 完全控制: 所有的业务逻辑,包括复杂的属性映射、会话管理、下游凭证生成,都由我们自己掌控,易于扩展和审计。
    • 高性能与低资源占用: GHC (Glasgow Haskell Compiler) 生成的原生代码性能优异,内存管理高效。对于一个无状态、计算密集型(密码学验证)的服务,Haskell 是一个非常合适的选择。
    • 技术沉淀: 虽然有初始学习曲线,但构建这样一个核心安全组件能够极大地提升团队在函数式编程和安全协议实现方面的工程能力。
  • 劣势:

    • 开发成本: 从零开始实现需要投入显著的研发资源,特别是处理 XML 签名验证这种密码学细节。
    • 生态与人才: Haskell 社区虽小但精,但找到有相关经验的工程师确实是一个挑战。项目需要有良好的文档和自动化测试来降低维护门槛。

最终决策: 考虑到该认证服务是整个内部平台的安全基石,我们不能在安全性、可控性上做任何妥协。我们选择方案 B。一次性的投入换来的是长期的稳定、安全与灵活性,这是值得的。这个中间件将成为平台工程的一部分,为所有业务团队提供透明的认证即服务。

核心实现概览

我们的整体架构如下:用户访问任何一个微前端应用,请求首先到达 API 网关。网关根据路由规则,将需要认证的请求转发给 Haskell SAML 中间件。中间件完成 SAML 响应的验证后,会生成一个包含用户身份信息的轻量级 JWT,并将其置于请求头中,再将请求转发给真正的上游微前端服务。

sequenceDiagram
    participant User as 用户
    participant Browser as 浏览器
    participant MFE as 微前端 (React/Vue)
    participant Gateway as API 网关
    participant SamlMiddleware as Haskell SAML 中间件
    participant IdP as 身份提供商 (SAML IdP)
    participant Backend as 后端服务

    User->>Browser: 访问内部工具 URL
    Browser->>MFE: 加载前端资源
    MFE->>Backend: 发起 API 请求 (无凭证)
    Note over MFE, Backend: 请求被网关拦截
    Gateway->>Gateway: 检查会话 Cookie,发现未认证
    Gateway->>Browser: 返回 302 重定向到 IdP
    Browser->>IdP: 发起 SAML AuthnRequest
    IdP-->>User: 要求登录
    User-->>IdP: 输入凭证
    IdP->>Browser: 登录成功,返回包含 SAMLResponse 的自提交表单
    Browser->>Gateway: POST /saml/acs (Assertion Consumer Service)
    Gateway->>SamlMiddleware: 转发 POST 请求 (包含 SAMLResponse)
    SamlMiddleware->>SamlMiddleware: 1. Base64 解码 SAMLResponse
    SamlMiddleware->>SamlMiddleware: 2. 解析 XML 并验证签名
    SamlMiddleware->>SamlMiddleware: 3. 校验断言有效期、受众等
    SamlMiddleware->>SamlMiddleware: 4. 提取用户属性
    SamlMiddleware->>SamlMiddleware: 5. 生成内部 JWT
    SamlMiddleware->>Gateway: 返回 200 OK,并在响应头中附带 JWT
    Gateway->>Gateway: 将 JWT 存入安全的 HttpOnly Cookie
    Gateway->>Browser: 重定向到最初访问的 URL
    Browser->>MFE: 再次加载页面
    MFE->>Backend: 再次发起 API 请求 (携带 Cookie)
    Gateway->>Backend: 验证 Cookie 中的 JWT,成功后将用户信息注入 Header (X-User-Info),转发请求
    Backend-->>MFE: 返回业务数据

1. 项目依赖与结构

我们使用 stack 作为项目管理工具。package.yaml 中的核心依赖如下:

# package.yaml
dependencies:
- base >= 4.7 && < 5
- aeson # 用于处理 JSON (JWT)
- wai # Web Application Interface
- warp # 高性能 Web Server
- http-types
- bytestring
- text
- base64-bytestring
- xml-conduit # 安全的 XML 流式解析
- saml2-core # SAML 协议核心类型与断言解析
- cryptonite # 密码学操作库
- x509
- x509-store
- x509-validation
- public-suffix-list
- memory # 用于证书存储
- wai-extra # 中间件相关工具
- unliftio # 异常处理
- containers
- lens # 便于访问嵌套数据

2. WAI 中间件核心逻辑

我们的服务本质上是一个 WAI (Web Application Interface) 应用。核心是一个 Middleware,它是一个函数,接收一个 Application,返回一个新的 Application

-- src/SamlMiddleware.hs
{-# LANGUAGE OverloadedStrings #-}

module SamlMiddleware (samlAcsMiddleware) where

import Network.Wai
import Network.HTTP.Types (status400, status401, status500, hContentType)
import qualified Data.ByteString.Lazy as LBS
import qualified Data.ByteString.Base64 as B64
import qualified Data.Text.Encoding as T
import Control.Monad.IO.Class (liftIO)
import Control.Exception.Safe (try, SomeException)

import Config (AppConfig(..), IdpConfig(..))
import SamlValidator (validateSamlResponse)
import TokenGenerator (generateInternalJwt)

-- | WAI 中间件,用于处理 SAML ACS (Assertion Consumer Service) 的 POST 请求
samlAcsMiddleware :: AppConfig -> Middleware
samlAcsMiddleware config app req sendResponse =
  -- 仅拦截我们配置的 ACS 路径
  if pathInfo req == ["saml", "acs"] && requestMethod req == "POST"
    then handleAcsRequest config req sendResponse
    else app req sendResponse

-- | 处理 ACS 请求的核心逻辑
handleAcsRequest :: AppConfig -> Request -> (Response -> IO ResponseReceived) -> IO ResponseReceived
handleAcsRequest config req sendResponse = do
  -- 从 POST 表单中提取 SAMLResponse
  body <- strictRequestBody req
  case lookup "SAMLResponse" (parseSimpleQuery (LBS.toStrict body)) of
    Nothing -> sendErrorResponse status400 "SAMLResponse not found in POST body"
    Just samlResponseBase64 -> do
      -- Base64 解码
      case B64.decode samlResponseBase64 of
        Left err -> sendErrorResponse status400 ("Base64 decoding failed: " ++ err)
        Right samlResponseXML -> do
          -- 核心:验证 SAML 响应
          validationResult <- liftIO $ try $ validateSamlResponse (idpConfig config) samlResponseXML
          case validationResult of
            Left ex -> do
              -- 在真实项目中,这里应该使用结构化日志记录异常 ex
              liftIO $ putStrLn $ "SAML validation failed: " ++ show (ex :: SomeException)
              sendErrorResponse status401 "SAML validation failed"
            Right userInfo -> do
              -- 验证成功,生成内部 JWT
              internalToken <- liftIO $ generateInternalJwt (jwtConfig config) userInfo
              
              -- 在真实项目中,这里会设置一个 secure, httpOnly 的 cookie
              -- 并重定向用户到原始请求地址。为简化示例,我们直接返回 Token。
              let response = responseLBS status200 [(hContentType, "application/jwt")] (LBS.fromStrict internalToken)
              sendResponse response
  where
    sendErrorResponse status msg =
      sendResponse $ responseLBS status [(hContentType, "text/plain; charset=utf-8")] (LBS.fromStrict $ T.encodeUtf8 msg)

3. 安全的 SAML 断言验证

这是整个服务中最关键且最复杂的部分。我们必须严格验证 XML 签名,并检查断言的各种约束条件。

-- src/SamlValidator.hs
{-# LANGUAGE OverloadedStrings #-}

module SamlValidator where

import qualified Data.ByteString.Lazy as LBS
import qualified Data.Text as T
import Data.Time.Clock (getCurrentTime)
import Data.Maybe (listToMaybe)
import Control.Monad.Trans.Except (runExceptT, throwE)
import Control.Lens ((^.))

import Text.XML
import SAML2.XML.Signature
import SAML2.Core.Assertions
import SAML2.Core.Protocols
import Crypto.X509 (getSystemStore, storeReadCertificate)
import Crypto.X509.Store (makeCertificateStore)
import Crypto.X509.Validation

import Config (IdpConfig(..))
import Types (UserInfo(..)) -- 自定义的用户信息类型

-- | 验证 SAML 响应,成功则返回 UserInfo,失败则抛出异常
validateSamlResponse :: IdpConfig -> LBS.ByteString -> IO UserInfo
validateSamlResponse idpConfig xmlBytes = do
  -- 1. 解析 XML 文档。xml-conduit 会处理好格式错误
  let doc = parseLBS_ def xmlBytes
  
  -- 2. 解码 SAML 响应体。saml2-core 库提供了从 XML 到 Haskell 类型的转换
  samlResponse <- case fromXML (documentRoot doc) of
    Left err -> fail $ "Failed to decode SAML Response: " ++ show err
    Right resp -> return resp

  -- 3. 验证响应状态
  let statusCode = samlResponse ^. responseStatus . status . statusCodeValue
  unless (statusCode == "urn:oasis:names:tc:SAML:2.0:status:Success") $
    fail $ "SAML Response status was not Success: " ++ T.unpack statusCode
    
  -- 4. 提取签名和断言
  let mAssertion = listToMaybe (samlResponse ^. responseAssertions)
  let mSignature = samlResponse ^. responseSignature
  
  assertion <- case mAssertion of
    Nothing -> fail "No Assertion found in SAML Response"
    Just a -> return a

  -- 5. 验证签名 (至关重要)
  -- 在真实项目中,IdP 的公钥证书应该从配置中安全加载
  let idpCert = case storeReadCertificate (idpSigningCert idpConfig) of
                  [cert] -> cert
                  _      -> error "Failed to parse IdP signing certificate"
  
  -- saml2-core 提供了验证函数,它会处理 XML 规范化 (c14n) 和摘要算法
  let signatureValid = verifySAML signableSAML (signedID signableSAML) idpCert
        where signableSAML = Signed (signature' mSignature) assertion
              signature' (Just s) = s
              signature' Nothing = error "Signature is missing"

  unless signatureValid $
    fail "SAML Response signature verification failed"

  -- 6. 验证断言条件
  now <- getCurrentTime
  let conditions = assertion ^. assertionConditions
  
  -- 检查 NotBefore 和 NotOnOrAfter
  case conditions ^. conditionsNotBefore of
    Just t | now < t -> fail "SAML Assertion is not yet valid (NotBefore)"
    _ -> return ()
  case conditions ^. conditionsNotOnOrAfter of
    Just t | now >= t -> fail "SAML Assertion has expired (NotOnOrAfter)"
    _ -> return ()
    
  -- 检查 AudienceRestriction
  let audience = listToMaybe =<< (conditions ^. conditionsAudienceRestrictions)
  case audience of
    Just aud | aud ^. audienceURI /= spEntityId idpConfig ->
      fail $ "Audience restriction mismatch. Expected: " ++ T.unpack (spEntityId idpConfig)
    Nothing -> fail "Audience restriction is missing"
    _ -> return ()

  -- 7. 提取用户属性
  -- Subject -> NameID
  let subjectNameID = assertion ^. assertionSubject . subjectNameID . nameIDValue
  
  -- AttributeStatement
  let findAttr name = listToMaybe $ do
        stmt <- assertion ^. assertionAttributeStatements
        attr <- stmt ^. statementAttributes
        guard (attr ^. attributeName == name)
        val <- attr ^. attributeValues
        return $ attributeValue val

  let userEmail = case findAttr "email" of
                    Just (XMLString email) -> email
                    _ -> T.pack "email_not_found" -- 生产代码应该有更严格的处理
  
  let userName = case findAttr "displayName" of
                   Just (XMLString name) -> name
                   _ -> subjectNameID

  -- 8. 构建并返回用户信息
  return UserInfo
    { userId = subjectNameID
    , userName = userName
    , userEmail = userEmail
    }

这段 Haskell 代码虽然比等效的动态语言代码更长,但它提供了编译时的保证。saml2-core 库将复杂的 XML 结构映射为 Haskell 的记录类型,lens 库让我们能以相对直观的方式 (^.) 访问深层嵌套的字段。任何字段的缺失或类型不匹配都会导致编译错误,而不是运行时崩溃。签名验证直接调用 verifySAML,其内部封装了复杂的 XML 规范化和密码学计算,我们只需要提供正确的证书。

前端与样式系统的整合

虽然后端是 Haskell,但前端可以是任何技术栈,如 React、Vue 或 Angular。这就是微前端架构的优势。

对于用户认证流程,前端逻辑非常简单:

  1. 应用加载时,API 请求如果返回 401/403,说明用户未登录。
  2. 此时,前端不需要处理任何 SAML 逻辑,只需将页面重定向到 API 网关提供的一个固定端点,例如 /auth/login
  3. 这个端点会触发网关生成 AuthnRequest 并重定向到 IdP 的流程。
  4. 用户在 IdP 登录成功后,后续流程如上图所示,最终用户浏览器会被重定向回应用,并携带了认证 Cookie。此后 API 请求就会成功。

至于 Sass/SCSS,它在整个架构中扮演着“视觉统一层”的角色。尽管每个微前端应用是独立开发和部署的,但它们需要共享一套统一的 UI/UX 规范。我们的实践是:

  • 建立一个独立的 Design System 项目: 这个项目使用 Sass/SCSS 定义了所有的设计令牌(颜色、字体、间距)、基础组件样式和全局 CSS 工具类。
  • 以 npm 包形式分发: Design System 被编译成 CSS 文件,并打包成一个私有的 npm 包发布。
  • 微前端应用消费: 每个微前端项目都 npm install 这个样式包,并在其主入口文件中导入编译好的 CSS。
// 在 design-system/src/tokens/_colors.scss
$primary-color: #007bff;
$text-color: #333;
$border-color: #dee2e6;

// 在 design-system/src/components/_button.scss
@import '../tokens/colors';

.btn {
  display: inline-block;
  font-weight: 400;
  text-align: center;
  white-space: nowrap;
  vertical-align: middle;
  border: 1px solid transparent;
  padding: .375rem .75rem;
  font-size: 1rem;
  line-height: 1.5;
  border-radius: .25rem;
  transition: color .15s ease-in-out, background-color .15s ease-in-out;
}

.btn-primary {
  color: #fff;
  background-color: $primary-color;
  border-color: $primary-color;
}

通过这种方式,Sass/SCSS 实现了跨多个独立前端应用的样式复用与统一管理,保证了我们内部平台的一致性体验。

架构的扩展性与局限性

这个基于 Haskell 的 SAML 中间件方案,为我们的平台工程提供了坚实的安全基础。

扩展性:

  • 多 IdP 支持: 我们可以轻易地修改配置,加载多个 IdP 的元数据和证书,通过不同的 ACS 路径来支持不同的身份提供商。
  • 协议扩展: 如果未来需要支持 OIDC,我们可以在此服务中新增一个 OIDC 处理分支,复用大部分下游 JWT 生成和会话管理的逻辑。
  • 自定义断言处理: 任何复杂的属性映射和权限逻辑都可以在 validateSamlResponse 函数的末尾进行扩展,代码清晰,易于测试。

局限性:

  • 维护成本: Haskell 虽然强大,但团队需要持续投入学习和维护。新成员的上手曲线是客观存在的。必须配备完善的单元测试、集成测试和 CI/CD 流水线来保障代码质量和快速迭代。
  • 单点依赖: 尽管该服务本身是无状态的,可以水平扩展,但它仍然是认证流程中的一个关键节点。我们需要确保其高可用性,通常会部署多个实例并进行健康检查和负载均衡。
  • SAML 协议的复杂性: 此实现只处理了核心的 IdP-Initiated 和 SP-Initiated 的 POST-Binding 流程。SAML 协议还有许多其他绑定(Redirect-Binding, Artifact-Binding)和特性(SLO - 单点登出),如果要完全覆盖,工作量会相当巨大。对于我们的内部平台场景,当前实现已足够健壮。

  目录