土法炼钢兴趣小组的算法知识备份

【身份与访问控制工程】SAML 还值得学吗:企业遗留 SSO 的现实世界

文章导航

分类入口
architecturesecurity
标签入口
#SAML#SSO#enterprise#federation#XML#identity#security

目录

某个周三下午,一家做 DevOps 可观测性平台的初创公司刚刚签下了他们第一个金融行业客户——一家区域性商业银行,年合同金额约六十万美元。合同签字页还没寄到,对方 IT 安全团队的邮件已经先到了,标题是”SSO Onboarding Checklist for Vendor Integration”,正文写道:“请按附件元数据(metadata)配置 SAML 2.0 SP-initiated SSO,我们的 IdP 是 PingFederate 10.3,NameID 使用 emailAddress 格式,断言需要使用我方证书加密,RelayState 必须透传。接口上线前请提交 SP metadata XML 至以下邮箱,我们需要 3 个工作日完成对接评审。” 创始团队里没有任何人在生产环境碰过 SAML——他们的登录体系一直是 OIDC + Google Workspace。但合同已经签了,SLA 要求两周内完成集成,换句话说:这一周必须把一个 2005 年发布、基于 XML 数字签名、充满命名空间细节的协议,在生产系统上跑通,并通过对方安全团队的测试。这篇文章就是写给这类工程师的:你未必喜欢 SAML,但你必须能在一周内交付它。

SAML 2.0 SP-initiated 流程

在 IAM 工程体系里,SAML 属于”你以为已经被淘汰、但每次接大客户都会重新回来”的那一类协议。关于 IAM 全景,可以参考《IAM 全景:身份、认证、授权与治理的边界》;关于现代 OIDC SSO,可以对比《SSO 与 OIDC:从一次登录到统一身份》;关于认证系统整体架构,可以参考《认证架构:从密码到零信任》。本文专注于一个具体问题:当现代工程师第一次被甩一份 IdP metadata XML 时,应该知道什么。

一、为什么 2026 年还要学 SAML

1.1 一个 2005 年的协议为什么没死

SAML 2.0(Security Assertion Markup Language 2.0)由 OASIS 于 2005 年 3 月正式批准,比 OAuth 1.0(2010)早五年,比 OIDC(2014)早九年。它是 Web 时代第一个被广泛部署的联邦身份(federated identity)协议,也是最后一代以 XML 为默认数据格式的大型标准之一。在”XML over HTTP”仍是主流企业架构、SOAP 仍是跨系统调用范式的时代,SAML 的设计完全符合当时的工程审美:严格的 XML Schema、XML Digital Signature、XML Encryption、基于 SOAP 的后端通道。

SAML 没有死的原因不是技术优越,而是组织惯性:

第一,企业 IT 的首要 KPI 是”不出事”,而不是”用新东西”。一家美国中型保险公司可能在 2009 年采购了 PingFederate 集群,配置了与 Active Directory、Workday、Salesforce、ServiceNow 的联邦关系,在过去十五年里这套系统稳定地支撑了每天数万次登录、通过了每年一次的 SOC 2 和 SOX 审计。没有任何内部团队有动力去替换它——即便你能证明 OIDC 更现代,“替换 IdP”这个项目从立项到上线通常要 18 到 24 个月,涉及数百个 SP 的重新对接,风险远大于收益。

第二,SAML 深度嵌入了企业 HR 与业务 SaaS 生态。Workday、SAP SuccessFactors、ADP 这类 HRIS(Human Resources Information System)都原生支持 SAML 作为 IdP 或 SP;Salesforce、ServiceNow、Atlassian Cloud(Confluence、Jira)、Box、Dropbox Business、Workplace by Meta 对 SAML SP 的支持比 OIDC 更成熟稳定——许多场景下 OIDC 选项甚至要购买额外的”Premium SSO”附加包。换句话说,对企业 IT 管理员来说,SAML 仍是默认选项。

第三,老派企业 IdP 的存量巨大。PingFederate、CA/Broadcom SiteMinder、Oracle Access Manager、IBM Security Verify(前身 TFIM/ISAM)、Microsoft ADFS(Active Directory Federation Services)、Shibboleth(高等教育领域几乎垄断)——这些产品的客户群体对 SAML 的投入是以十年为单位的。Azure AD(现 Entra ID)和 Okta 虽然同时支持 SAML 与 OIDC,但在真正的 B2B 对接场景中,企业客户给你的往往仍是 SAML metadata URL。

第四,合规与审计的惯性。许多金融、医疗、政府客户的安全团队有一份”认可协议清单”,SAML 2.0 在上面,OIDC 可能还在评估。对方给不了批准,你就上不了线。

1.2 SAML 与 OIDC 的工程定位对比

维度 SAML 2.0 OIDC / OAuth 2.1
数据格式 XML(严格 Schema) JSON / JWT
签名 XML Digital Signature(Canonicalization + 签名) JWS(紧凑、字符串级)
主场景 企业内网 / B2B Web SSO 公网 / 移动端 / API 授权
移动端支持 差(浏览器重定向强绑定) 原生良好
传输 浏览器前端通道(Redirect / POST) 前端重定向 + 后端 Token 端点
刷新机制 无(重新走 SSO) refresh_token
典型 IdP ADFS、PingFederate、Shibboleth Okta、Auth0、Entra ID、Keycloak
断言携带 结构化 XML,属性映射丰富 id_token(claims) + userinfo
攻击面 XML Signature Wrapping、XXE、XSLT JWT alg=none、redirect_uri、CSRF

这张表解释了为什么 2026 年还要学 SAML:对接企业客户时你没得选;但它同时也解释了为什么你自己搭的新系统几乎一定应该选 OIDC——XML 栈的复杂度、签名验证的实现坑、移动端的天然不适配,使得 SAML 作为”主路径”的日子已经过去。

1.3 什么时候必须学 SAML

以下场景里学 SAML 不是加分项,而是入场券:

如果你的用户只是个人消费者,或者 B2B 只面向其他初创公司,那么你可以把本文当作知识储备。否则,继续往下读。

二、SAML 2.0 核心概念速览

2.1 三个参与方

SAML 定义了一个三方关系,术语必须记住:

这和 OIDC 的 RP(Relying Party)/ OP(OpenID Provider)/ End-User 是对应关系:IdP ≈ OPSP ≈ RPPrincipal ≈ End-User

2.2 三种断言类型

断言(Assertion)是 IdP 签发给 SP 的 XML 文档,声明”关于某个主体的某个事实”。SAML 2.0 规范定义了三种:

2.3 四种绑定

绑定(Binding)规定”SAML 消息如何通过某种传输层送达”。SAML 2.0 规范里有多种,工程中只需记住四种:

2.4 几个关键协议消息

三、断言结构:从一份真实的 SAMLResponse 开始

3.1 AuthnRequest 示例

以下是 SP 发给 IdP 的一个典型 AuthnRequest(未压缩编码前的 XML):

<samlp:AuthnRequest
    xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol"
    xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion"
    ID="_9f3c2b6a-4a9d-4e87-9f14-4b0b33b4b8a1"
    Version="2.0"
    IssueInstant="2026-04-21T09:12:33Z"
    Destination="https://idp.example-bank.com/idp/SSO.saml2"
    ProtocolBinding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"
    AssertionConsumerServiceURL="https://app.vendor.io/saml/acs">
  <saml:Issuer>https://app.vendor.io/saml/metadata</saml:Issuer>
  <samlp:NameIDPolicy
      Format="urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress"
      AllowCreate="true"/>
  <samlp:RequestedAuthnContext Comparison="exact">
    <saml:AuthnContextClassRef>
      urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport
    </saml:AuthnContextClassRef>
  </samlp:RequestedAuthnContext>
</samlp:AuthnRequest>

几个必须理解的字段:

通过 HTTP Redirect 发送时,上面这段 XML 会被 DEFLATE(原始 deflate,RFC 1951,不是 zlib 头)压缩、再 Base64 编码、再 URL 编码,最终成为 ?SAMLRequest=... 参数。如果 SP 有自己的签名密钥,还会附加 SigAlg=...&Signature=...(即所谓 Redirect-Signing,签名是对拼接后的查询字符串做的,不是对 XML 做的)。

3.2 SAMLResponse 示例

以下是 IdP 签发的 Response(已省略 base64 编码环节,展示解码后结构):

<samlp:Response
    xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol"
    xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion"
    ID="_0e1a8f9d-44b2-4d21-8a3b-6f5c0e0dcb10"
    InResponseTo="_9f3c2b6a-4a9d-4e87-9f14-4b0b33b4b8a1"
    Version="2.0"
    IssueInstant="2026-04-21T09:12:41Z"
    Destination="https://app.vendor.io/saml/acs">
  <saml:Issuer>https://idp.example-bank.com/idp</saml:Issuer>
  <samlp:Status>
    <samlp:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"/>
  </samlp:Status>

  <saml:Assertion
      ID="_3b9a7c4e-6d01-42b7-91ff-2a4f5d7e3210"
      Version="2.0"
      IssueInstant="2026-04-21T09:12:41Z">
    <saml:Issuer>https://idp.example-bank.com/idp</saml:Issuer>

    <!-- 数字签名在此(enveloped signature),省略 -->
    <ds:Signature xmlns:ds="http://www.w3.org/2000/09/xmldsig#">...</ds:Signature>

    <saml:Subject>
      <saml:NameID Format="urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress">
        alice.chen@example-bank.com
      </saml:NameID>
      <saml:SubjectConfirmation Method="urn:oasis:names:tc:SAML:2.0:cm:bearer">
        <saml:SubjectConfirmationData
            NotOnOrAfter="2026-04-21T09:17:41Z"
            Recipient="https://app.vendor.io/saml/acs"
            InResponseTo="_9f3c2b6a-4a9d-4e87-9f14-4b0b33b4b8a1"/>
      </saml:SubjectConfirmation>
    </saml:Subject>

    <saml:Conditions
        NotBefore="2026-04-21T09:12:11Z"
        NotOnOrAfter="2026-04-21T09:17:41Z">
      <saml:AudienceRestriction>
        <saml:Audience>https://app.vendor.io/saml/metadata</saml:Audience>
      </saml:AudienceRestriction>
    </saml:Conditions>

    <saml:AuthnStatement
        AuthnInstant="2026-04-21T09:12:40Z"
        SessionIndex="_sess-82ab14e6"
        SessionNotOnOrAfter="2026-04-21T17:12:40Z">
      <saml:AuthnContext>
        <saml:AuthnContextClassRef>
          urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport
        </saml:AuthnContextClassRef>
      </saml:AuthnContext>
    </saml:AuthnStatement>

    <saml:AttributeStatement>
      <saml:Attribute Name="email"
          NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:basic">
        <saml:AttributeValue>alice.chen@example-bank.com</saml:AttributeValue>
      </saml:Attribute>
      <saml:Attribute Name="displayName">
        <saml:AttributeValue>Alice Chen</saml:AttributeValue>
      </saml:Attribute>
      <saml:Attribute Name="department">
        <saml:AttributeValue>Risk Management</saml:AttributeValue>
      </saml:Attribute>
      <saml:Attribute Name="groups">
        <saml:AttributeValue>vendor-admins</saml:AttributeValue>
        <saml:AttributeValue>risk-analysts</saml:AttributeValue>
      </saml:Attribute>
    </saml:AttributeStatement>
  </saml:Assertion>
</samlp:Response>

SP 必须校验的内容(顺序很重要):

  1. XML Schema 合法,未包含 DOCTYPE(防 XXE),未包含不期望的外部引用;
  2. 签名算法在白名单内(禁用 SHA-1 / RSA-MD5 等,禁止 alg=none 之类的极端情况);
  3. 签名覆盖 <Assertion><Response>(见下文 XSW);
  4. 签名验证通过,使用预先交换的 IdP X509 证书或其 SubjectKeyIdentifier 匹配;
  5. Issuer 与 SP 对该 IdP 的预期一致;
  6. Destination 等于当前 ACS URL,Recipient 同样等于 ACS URL;
  7. InResponseTo 存在且等于 SP 侧保存的某个未消费 AuthnRequest ID;
  8. NotBeforeNotOnOrAfter 覆盖当前时间(允许 ±2~3 分钟时钟偏移),SubjectConfirmationData/NotOnOrAfter 同样有效;
  9. AudienceRestriction/Audience 包含自己的 EntityID;
  10. 断言未被消费过(ID 去重,通常基于 Assertion ID 做一段时间 Redis 缓存);
  11. 通过后提取 NameID / Attributes,落地本地 session。

这 11 步里任何一步 SP 实现不全,都会出现现实中真实发生过的安全事故。

3.3 签名位置:Response 级 vs Assertion 级

SAML 允许签名出现在两个位置:

一些 IdP 同时签名两者。SP 实现必须明确声明自己的策略——最健壮的做法是只信任 Assertion 级签名(不管 Response 是否额外签名),且在验证签名后以签名过的 Assertion 元素为唯一数据源,不要回退到 DOM 里任何未被签名覆盖的同名节点。这是防御 XML Signature Wrapping 的关键。

四、SP-initiated 流程逐步时序

4.1 完整步骤

  1. 用户访问 SP 的受保护资源:浏览器 GET https://app.vendor.io/dashboard,SP 发现无本地 session。

  2. SP 构造 AuthnRequest:生成 ID,记录到短期存储(例如 Redis,key 即 ID,TTL 5 分钟),将 Destination 设为 IdP SSO endpoint。选择 Redirect Binding 则 DEFLATE+Base64+URL-encode,选择 POST Binding 则只 Base64。

  3. SP 把浏览器重定向到 IdP:Redirect Binding 下返回 302 Location: https://idp.example-bank.com/idp/SSO.saml2?SAMLRequest=...&RelayState=<不透明>。RelayState 通常用于告诉自己登录完成后跳回哪个页面,不要把原始 URL 直接塞进去,而是塞一个 opaque key,server side 映射真实 URL。

  4. IdP 认证用户:如果 IdP 没有会话,弹出自己的登录页;如果有会话,直接进入下一步。此阶段 MFA、条件访问策略(Conditional Access)、设备合规检查等都在 IdP 侧完成,SP 看不见。

  5. IdP 签发 SAMLResponse:生成包含 Assertion 的 Response,签名、(如配置)加密,Base64 编码,返回给浏览器一个自动提交的 HTML form:

    <html>
    <body onload="document.forms[0].submit()">
      <form method="POST" action="https://app.vendor.io/saml/acs">
        <input type="hidden" name="SAMLResponse" value="PHNhbWxwOlJlc3BvbnNl...">
        <input type="hidden" name="RelayState" value="opaque-key-abc">
      </form>
    </body>
    </html>
  6. 浏览器 POST 到 SP 的 ACS URL:SP 执行上节 11 步校验,通过后建立本地 session(set-cookie sid=...; HttpOnly; Secure; SameSite=Lax),然后使用 RelayState 映射出的真实 URL 做 302 跳转。

4.2 IdP-initiated 流程

有些场景下是 IdP 主动发起,比如企业员工的门户页(如 Okta Dashboard)上有一堆应用图标,点击后直接 POST 一份 SAMLResponse 到 SP 的 ACS URL,不存在先前的 AuthnRequest

这条流程的安全风险比 SP-initiated 高得多:

现代指导建议:除非客户强制要求,否则关闭 IdP-initiated 入口;必须支持时,要求请求带一次性 Token(SP 生成的 CSRF-like “unsolicited response nonce”,或至少在 ACS 处理成功后显示确认页让用户主动点击”Continue as …“)。OWASP SAML Cheatsheet 与 NIST SP 800-63C 对此都有明确警示。

4.3 RelayState 的正确姿势

RelayState 的语义是”协议透明的状态参数”,用于让 SP 在 SSO 完成后记起”用户原本要去哪里”。实现错误的常见姿势有:

RelayState = https://app.vendor.io/dashboard?project=123  // 错误:暴露内部 URL
RelayState = https://evil.com/phish                       // 错误:被利用做 open redirect

正确姿势:

# SP 端,发起 AuthnRequest 前
nonce = secrets.token_urlsafe(16)
redis.setex(f"relay:{nonce}", 600, json.dumps({
    "target_url": "/dashboard?project=123",
    "csrf": secrets.token_urlsafe(16),
}))
authn_request = build_authn_request(...)
redirect_to_idp(authn_request, relay_state=nonce)

# ACS 回调
payload = redis.get(f"relay:{request.form['RelayState']}")
if payload is None:
    raise BadRequest("invalid RelayState")
target = json.loads(payload)["target_url"]
assert target.startswith("/")           # 只允许相对路径
redis.delete(f"relay:{request.form['RelayState']}")  # 一次性

五、Metadata:SP 与 IdP 的”握手文件”

5.1 SP Metadata 必填

SP metadata 是你提供给客户 IdP 管理员的 XML 文件,告诉他们”我是谁、请把响应发到哪里、请用哪把公钥给我加密”。一个最小可用的 SP metadata 形如:

<EntityDescriptor xmlns="urn:oasis:names:tc:SAML:2.0:metadata"
                  entityID="https://app.vendor.io/saml/metadata">
  <SPSSODescriptor AuthnRequestsSigned="true"
                   WantAssertionsSigned="true"
                   protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
    <KeyDescriptor use="signing">
      <ds:KeyInfo xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
        <ds:X509Data><ds:X509Certificate>MIIDxx...signing</ds:X509Certificate></ds:X509Data>
      </ds:KeyInfo>
    </KeyDescriptor>
    <KeyDescriptor use="encryption">
      <ds:KeyInfo xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
        <ds:X509Data><ds:X509Certificate>MIIDxx...encryption</ds:X509Certificate></ds:X509Data>
      </ds:KeyInfo>
    </KeyDescriptor>
    <SingleLogoutService
        Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect"
        Location="https://app.vendor.io/saml/slo"/>
    <NameIDFormat>urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress</NameIDFormat>
    <AssertionConsumerService index="0" isDefault="true"
        Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"
        Location="https://app.vendor.io/saml/acs"/>
  </SPSSODescriptor>
  <Organization>
    <OrganizationName xml:lang="en">Vendor Inc.</OrganizationName>
    <OrganizationDisplayName xml:lang="en">Vendor</OrganizationDisplayName>
    <OrganizationURL xml:lang="en">https://vendor.io</OrganizationURL>
  </Organization>
  <ContactPerson contactType="technical">
    <EmailAddress>security@vendor.io</EmailAddress>
  </ContactPerson>
</EntityDescriptor>

关键属性:

5.2 IdP Metadata 解析

客户给你的 IdP metadata 结构类似,重点字段:

<IDPSSODescriptor WantAuthnRequestsSigned="true"
                  protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
  <KeyDescriptor use="signing">...X509Certificate...</KeyDescriptor>
  <NameIDFormat>urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress</NameIDFormat>
  <SingleSignOnService
      Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect"
      Location="https://idp.example-bank.com/idp/SSO.saml2"/>
  <SingleSignOnService
      Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"
      Location="https://idp.example-bank.com/idp/SSO.saml2"/>
</IDPSSODescriptor>

SP 必须提取:

5.3 Metadata 自动化

在生产中,不要把 metadata XML 写死在代码或配置里。推荐:

六、签名、加密、证书轮换

6.1 签名算法

SAML 2.0 的签名基于 XML Digital Signature(XMLDSIG)。常见算法 URI:

规范化(Canonicalization)方法:

工程陷阱:若客户 IdP 仍然只用 SHA-1,标准操作是写入合同或集成备忘录里记录这一限制,在 SP 侧显式为该 IdP 打开 SHA-1 白名单(隔离配置,不影响其他客户),并推动对方在下次大版本升级前切到 SHA-256。不要悄悄全局支持 SHA-1。

6.2 断言加密

当断言携带敏感信息(如 SSN、工资带、客户 ID)且你担心断言在浏览器-SP 路径上被记录(Web 代理日志、HAR 上传、浏览器扩展)时,使用 XML Encryption 加密 Assertion:

加密后的结构如下:

<saml:EncryptedAssertion>
  <xenc:EncryptedData Type="http://www.w3.org/2001/04/xmlenc#Element">
    <xenc:EncryptionMethod Algorithm="http://www.w3.org/2009/xmlenc11#aes256-gcm"/>
    <ds:KeyInfo>
      <xenc:EncryptedKey>
        <xenc:EncryptionMethod Algorithm="http://www.w3.org/2001/04/xmlenc#rsa-oaep-mgf1p"/>
        <xenc:CipherData><xenc:CipherValue>...CEK...</xenc:CipherValue></xenc:CipherData>
      </xenc:EncryptedKey>
    </ds:KeyInfo>
    <xenc:CipherData><xenc:CipherValue>...ciphertext...</xenc:CipherValue></xenc:CipherData>
  </xenc:EncryptedData>
</saml:EncryptedAssertion>

选择要点:AES-GCM(aes128-gcm / aes256-gcm)优于 AES-CBC(历史上 CBC 存在 padding oracle 等);许多库的默认仍是 CBC,需要显式切换。

6.3 证书轮换:双证书滚动

这是运维团队最常见的事故源之一。典型事故模板:“周一早上 9:03,500 多个 SSO 用户同时无法登录,错误信息是 Signature verification failed——原因是 IdP 管理员昨晚轮换了签名证书,忘了提前通知 SP 团队”。

SP 侧要做到不停机更换证书:

  1. 在 metadata 里同时保留两个 signing KeyDescriptor,一新一旧,任一能验证通过即视为签名有效;
  2. 代码层面允许 trusted_signing_certs 为列表,按指纹匹配;
  3. 新证书生效后等待至少 N 天(N ≥ 2 × 最大 session 生命周期),确认没有残留引用,再从配置里删除旧证书;
  4. 监控签名失败率,即使未全面失败也要告警;
  5. 证书到期前至少 30 天发出第一轮告警、14 天升级、7 天电话提醒。

IdP 侧同样:发布新证书前至少 30 天更新 metadata,让所有 SP 都拉取到”双证书”状态。

七、参考实现:Java 与 Python

7.1 Java:OpenSAML 4 最小示例

OpenSAML(由 Shibboleth 项目维护)是 JVM 生态里最主流的 SAML 库。构造 AuthnRequest:

import org.opensaml.core.config.InitializationService;
import org.opensaml.saml.saml2.core.*;
import org.opensaml.saml.saml2.core.impl.*;
import org.opensaml.core.xml.config.XMLObjectProviderRegistrySupport;

InitializationService.initialize();

XMLObjectBuilderFactory bf = XMLObjectProviderRegistrySupport.getBuilderFactory();
AuthnRequest ar = (AuthnRequest) bf
    .getBuilder(AuthnRequest.DEFAULT_ELEMENT_NAME)
    .buildObject(AuthnRequest.DEFAULT_ELEMENT_NAME);
ar.setID("_" + UUID.randomUUID());
ar.setVersion(SAMLVersion.VERSION_20);
ar.setIssueInstant(Instant.now());
ar.setDestination("https://idp.example-bank.com/idp/SSO.saml2");
ar.setProtocolBinding(SAMLConstants.SAML2_POST_BINDING_URI);
ar.setAssertionConsumerServiceURL("https://app.vendor.io/saml/acs");

Issuer issuer = (Issuer) bf.getBuilder(Issuer.DEFAULT_ELEMENT_NAME)
    .buildObject(Issuer.DEFAULT_ELEMENT_NAME);
issuer.setValue("https://app.vendor.io/saml/metadata");
ar.setIssuer(issuer);

NameIDPolicy nip = (NameIDPolicy) bf.getBuilder(NameIDPolicy.DEFAULT_ELEMENT_NAME)
    .buildObject(NameIDPolicy.DEFAULT_ELEMENT_NAME);
nip.setFormat(NameIDType.EMAIL);
nip.setAllowCreate(true);
ar.setNameIDPolicy(nip);

验证 Response 的关键片段(省略 context/criteria 设置):

ResponseUnmarshaller um = (ResponseUnmarshaller) registry.getUnmarshallerFactory()
    .getUnmarshaller(Response.DEFAULT_ELEMENT_NAME);
Response response = (Response) um.unmarshall(xmlElement);

SignatureValidator.validate(response.getAssertions().get(0).getSignature(), credential);

SAML20AssertionValidator validator = new SAML20AssertionValidator(
    conditionValidators, subjectConfirmationValidators, statementValidators,
    null, signaturePrevalidator, signatureTrustEngine);
ValidationResult result = validator.validate(response.getAssertions().get(0), context);
if (result != ValidationResult.VALID) {
    throw new SecurityException("Assertion rejected");
}

实务建议:不要自己写 XML 解析和签名验证。使用 OpenSAML、Spring Security SAML2、或 Keycloak SAML Adapter。

7.2 Python:python3-saml

OneLogin 的 python3-saml 库是 Python 生态事实标准:

from onelogin.saml2.auth import OneLogin_Saml2_Auth

def init_saml_auth(request):
    return OneLogin_Saml2_Auth(
        {
            "https": "on",
            "http_host": request.host,
            "server_port": "443",
            "script_name": request.path,
            "get_data": request.args.to_dict(),
            "post_data": request.form.to_dict(),
        },
        custom_base_path="/etc/app/saml",
    )

# 发起
def login(request):
    auth = init_saml_auth(request)
    return redirect(auth.login(return_to="/dashboard"))

# ACS 回调
def acs(request):
    auth = init_saml_auth(request)
    auth.process_response()
    errors = auth.get_errors()
    if errors:
        abort(400, "SAML errors: " + ", ".join(errors))
    if not auth.is_authenticated():
        abort(401)
    attrs = auth.get_attributes()
    nameid = auth.get_nameid()
    session["user"] = {"email": nameid, "groups": attrs.get("groups", [])}
    return redirect(auth.redirect_to(request.form.get("RelayState", "/")))

settings.json 里必须设置:

{
  "strict": true,
  "security": {
    "authnRequestsSigned": true,
    "wantAssertionsSigned": true,
    "wantAssertionsEncrypted": false,
    "signatureAlgorithm": "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256",
    "digestAlgorithm": "http://www.w3.org/2001/04/xmlenc#sha256",
    "rejectUnsolicitedResponsesWithInResponseTo": true,
    "wantMessagesSigned": true
  }
}

strict: true 是任何生产环境的强制要求——它打开全部时间窗、Audience、Destination 校验。把它设成 false 上线是最常见的低级错误。

7.3 Go:crewjam/saml

sp := saml.ServiceProvider{
    EntityID:          "https://app.vendor.io/saml/metadata",
    Key:               spKey,
    Certificate:       spCert,
    AcsURL:            mustURL("https://app.vendor.io/saml/acs"),
    MetadataURL:       mustURL("https://app.vendor.io/saml/metadata"),
    IDPMetadata:       idpMetadata,
    AllowIDPInitiated: false,
    SignatureMethod:   "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256",
}

注意 AllowIDPInitiated: false 是默认选项——除非客户强制要求,否则保留。

八、安全陷阱与 XML 签名包装攻击

8.1 XML Signature Wrapping(XSW)

XSW 是 SAML 历史上最具代表性的攻击。原理是:XML 签名的”被签对象”是通过 ds:Reference 的 URI 指针标识的(例如 URI="#_3b9a7c4e..."),签名验证时库会:

  1. 找到那个 ID 对应的元素,规范化,计算 hash;
  2. 与签名中的 DigestValue 比较;
  3. 用公钥验证 SignedInfo。

但签名验证之后,应用代码是从哪个 DOM 节点读取 Subject/NameID/Attributes 的?如果读取的是 “找到的第一个 <Assertion>” 或类似 XPath,而 XML 文档里被攻击者额外塞入了另一个同名元素,且签名库只验证了原始带 ID 的那一份——应用就可能读到未被签名的伪造断言。

一个经典 XSW 变体 payload(简化):

<samlp:Response>
  <!-- 攻击者伪造的 Assertion(未签名),放在前面 -->
  <saml:Assertion ID="_evil">
    <saml:Subject><saml:NameID>attacker@evil.com</saml:NameID></saml:Subject>
    <!-- ... 伪造属性 ... -->
  </saml:Assertion>

  <!-- IdP 原始的、签名真实覆盖的 Assertion -->
  <saml:Assertion ID="_3b9a7c4e...">
    <ds:Signature>
      <ds:SignedInfo>
        <ds:Reference URI="#_3b9a7c4e...">...</ds:Reference>
      </ds:SignedInfo>
      <ds:SignatureValue>...</ds:SignatureValue>
    </ds:Signature>
    <saml:Subject><saml:NameID>alice.chen@example-bank.com</saml:NameID></saml:Subject>
  </saml:Assertion>
</samlp:Response>

此时如果代码写成:

// 错误代码!
Response resp = unmarshall(xml);
SignatureValidator.validate(resp.getSignature(), cred);  // 验证通过
NameID name = resp.getAssertions().get(0).getSubject().getNameID();  // 读到了 _evil

就被绕过了。SAML 安全研究者至少总结出 7 种 XSW 变体(Juraj Somorovsky 等人 2012 年论文),差别在于攻击者将伪造节点塞在文档的哪个位置:根前、签名兄弟、签名子节点、旧签名引用链外等。

8.2 XSW 的正确防御

8.3 Duplicate Assertion / 重放

即使签名正确,同一份 SAMLResponse 能用第二次就是事故。防御:

8.4 RelayState 开放重定向

若 SP 在 SSO 完成后把 RelayState 当作 Location 直接跳转,而 RelayState 本身可被任意构造,就是教科书级 open redirect。除了前文”只允许相对路径 + 服务端 opaque key”的写法,还可以在配置里维护白名单域名并严格校验。

8.5 IdP-initiated 下的 CSRF / Response Injection

前文已述。补一条具体缓解:ACS 端在”首次看到无 InResponseTo 的 Response”时,跳转到一个确认页(“You are logging in as Alice Chen from Example Bank, continue?”),只有用户主动点击才建立 session。这能阻断大多数”诱骗 Victim POST 攻击者的 SAMLResponse”场景。

8.6 元数据不信任 / 中间人

若 SP 从客户给的 HTTP URL 拉取 metadata(而非 HTTPS,或虽 HTTPS 但未 pin 证书),中间人可以替换 metadata 里的签名证书,从而签发任意断言。缓解:

8.7 其他速查清单

风险 要点
XXE 禁用 DTD / 外部实体
XSLT 注入 禁用 XSLT Transform
时钟偏移 允许 ±3 分钟,超过触发告警
NameID 混淆 明确 Format,持久 NameID 不随邮箱变更
Session 绑定 Assertion 成功后重新生成 session id
SLO 实现不全 可接受,但文档要写明

九、SAML 与 OIDC 的共存与迁移

9.1 共存策略

大多数 B2B SaaS 在现实中是这样的:新客户走 OIDC,老客户走 SAML。同一个用户对象模型在后端,只是前端认证路径有两条:

                  ┌─ /sso/oidc/{tenant}  ──► OIDC flow  ─┐
Login page  ─────►│                                      ├──► session
                  └─ /sso/saml/{tenant}  ──► SAML flow  ─┘

tenant 是客户标识,配置里绑定该客户的 IdP 详情。设计要点:

9.2 代理桥接模式

如果你的后端不想直接讲 SAML(例如主产品已深度依赖 OIDC 库、上游服务是纯 JWT 鉴权),可以引入桥接组件:

桥接模式的架构示意:

Client IdP  ──SAML──►  Keycloak  ──OIDC──►  Your App
(PingFederate)        (broker realm)       (standard RP)

权衡:桥接降低了应用团队的认知负担,但引入了新的运维目标(Keycloak 的高可用、升级、漏洞跟踪),并且 SAML 侧的坑(证书轮换、XSW 防御)仍然存在,只是下沉到了 Keycloak 运维团队。

9.3 原生接入 vs 桥接:怎么选

场景 原生接 SAML 用 Keycloak / Auth0 桥接
已有成熟 Java / XML 安全经验 更合适 也可行,但收益没那么大
主系统已全面 OIDC 化 成本偏高 优先选择
只有 1–2 个大客户需要 SAML 可接受 视团队能力决定
未来会有 10+ 企业客户接入 容易形成维护负担 更适合长期复用
团队没有专职身份平台运维 风险较高 采购型桥接更稳
需要统一审计、统一 session、统一 token 形态 需要自己整合 桥接优势明显

一句话判断:如果你的应用内部已经是 OIDC / JWT 世界,就尽量不要把 XML 解析和签名验证逻辑直接引进业务代码。

9.4 何时真正迁移

企业客户从 SAML 迁到 OIDC 的动因通常是:

迁移本身是一项独立工程,参见《SSO 与 OIDC:从一次登录到统一身份》。对 SP 而言,最低成本策略是让两条路径并存 6-12 个月,按租户切换,保留回滚窗口。

十、工程坑点清单

10.1 实施期常见坑

10.2 生产期常见事故

十一、选型与迁移建议

11.1 客户联调 checklist

和企业客户 IT 团队联调前,最好一次把下面这些信息要齐:

没有这张清单,后续绝大多数「为什么就是登不上」都会变成跨公司来回扯皮。

回到开头那家初创公司:签下银行合同的第二周,他们选择不自己写 SAML 代码,而是在现有 OIDC 架构前面部署了一台 Keycloak 作为 SAML-to-OIDC broker。第 11 天通过了银行 IT 的联调,第 14 天 GA 上线。六个月后他们又签下两家保险客户,broker 路径被复用,零额外开发量。SAML 并不值得”学得精通”;但值得你掌握到”能一周内让客户满意、不出安全事故”的程度——这正是本文的目标。

十二、参考资料


上一篇OAuth 2.1 与 PKCE:现代授权主路径

下一篇SCIM 与账号生命周期:开通、变更、离职自动化

同主题继续阅读

把当前热点继续串成多页阅读,而不是停在单篇消费。

2026-04-21 · architecture / security

【身份与访问控制工程】IAM 全景:为什么这是高价值赛道

身份与访问控制从一个登录框演进为横跨合规、运维、平台工程和安全的系统工程。本文从一家 SaaS 公司被大客户卡在 SOC 2 合规的真实触发器切入,拆解 IAM、CIAM、IGA、PAM、SSO、目录服务六个子领域的边界,分析 Okta、Entra ID、Auth0、Keycloak、Ping 等主流厂商的定位与落差,给出工程师视角的介入判据与选型路径。

2026-04-21 · architecture / security

【身份与访问控制工程】企业单点登录:OIDC 与现代 SSO

B2B 大客户的安全团队只认自家 Okta,集团五条产品线要统一账号,合规团队要求所有入口都可审计——这些需求最终都落在同一个协议上:OpenID Connect。本文从一个真实的商业谈判场景切入,系统拆解 OIDC 在 OAuth 2.0 之上增加了什么、授权码流程的每一个字段为什么存在、企业集成里最常见的六类实现坑,以及 Discovery、JWKS、Dynamic Client Registration、mTLS Client Auth 的工程细节,帮助你把 SSO 从 demo 做到可以放进 SOC 2 审计报告里。


By .