跳到主要内容
ecdsa.com
← 全部文章

SAML 签名验证:检查什么,为什么检查

SAML 签名可能完全有效,而你的代码从同一文档中读取的身份却不在它的覆盖范围内。本文介绍 XML 签名如何指向其签名对象、SAML 规范要求的各项检查,以及一个校验器,并用经过包装、插入注释、重放和重新签名的响应对它做了测试。

简短回答:校验 SAML 响应分为两部分,大多数安全事件都是因为只做了第一部分。第一,用身份提供商的证书验证 XML 签名——证书取自其元数据,绝不取自消息中的 KeyInfo;并要求恰好有一个 Reference,且它指向你将要使用的 Assertion(或 Response)的 ID。第二,你所依赖的每个值(NameID、属性、Issuer、Audience、Recipient、时间)都要从该签名覆盖的元素中读取,而不是在收到的文档中搜索:XML 文档可以在已签名元素旁边再包含一个外形相同、但未签名的元素。然后应用 SAML 规则:受众是你的实体 ID;bearer 类型的 SubjectConfirmationData 指明的是你的断言消费者 URL 和你的请求;没有任何内容过期;每个断言 ID 只接受一次。

XML 签名如何指向其签名对象

XML 签名(XML Signature Syntax and Processing 1.1)并不是把文档当作字节来签名。它签名的是 SignedInfo 元素,其中列出一个或多个 Reference。每个 Reference 都包含一个选取内容的 URI、一组作用于该内容的 Transforms,以及结果的 DigestValue。下面是一个 SAML 签名,由后文的实验用身份提供商签发,较长的 base64 值已缩短:

已签名的 SAML Response(show.py 的输出,base64 值已缩短)
<samlp:Response xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" ID="_ef6e7d7e83384141a7a16e6fb9d52d61" Version="2.0" IssueInstant="2026-10-20T13:39:36Z" Destination="https://sp.example/saml/acs" InResponseTo="_req-7d1f">
  <saml:Issuer>https://idp.example/saml</saml:Issuer>
  <samlp:Status><samlp:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"/></samlp:Status>
  <saml:Assertion ID="_cf908bd7284743f8997ddd7943f6640c" Version="2.0" IssueInstant="2026-10-20T13:39:36Z">
    <saml:Issuer>https://idp.example/saml</saml:Issuer>
    <ds:Signature xmlns:ds="http://www.w3.org/2000/09/xmldsig#"><ds:SignedInfo><ds:CanonicalizationMethod Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/><ds:SignatureMethod Algorithm="http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha256"/><ds:Reference URI="#_cf908bd7284743f8997ddd7943f6640c"><ds:Transforms><ds:Transform Algorithm="http://www.w3.org/2000/09/xmldsig#enveloped-signature"/><ds:Transform Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/></ds:Transforms><ds:DigestMethod Algorithm="http://www.w3.org/2001/04/xmlenc#sha256"/><ds:DigestValue>genlRcOfIZpWA8BHZ0Eo42LeZcRMriskgTApPDxsPI0=</ds:DigestValue></ds:Reference></ds:SignedInfo><ds:SignatureValue>cPF5v/9Ve6uUmgcn...</ds:SignatureValue><ds:KeyInfo><ds:X509Data><ds:X509Certificate>MIIBPDCB4qADAgEC...</ds:X509Certificate></ds:X509Data></ds:KeyInfo></ds:Signature><saml:Subject>
      <saml:NameID Format="urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress">alice@example.com</saml:NameID>
      <saml:SubjectConfirmation Method="urn:oasis:names:tc:SAML:2.0:cm:bearer">
        <saml:SubjectConfirmationData NotOnOrAfter="2026-10-20T13:44:36Z" Recipient="https://sp.example/saml/acs" InResponseTo="_req-7d1f"/>
      </saml:SubjectConfirmation>
    </saml:Subject>
    <saml:Conditions NotBefore="2026-10-20T13:39:36Z" NotOnOrAfter="2026-10-20T13:44:36Z">
      <saml:AudienceRestriction><saml:Audience>https://sp.example/saml</saml:Audience></saml:AudienceRestriction>
    </saml:Conditions>
    <saml:AuthnStatement AuthnInstant="2026-10-20T13:39:36Z"><saml:AuthnContext>
      <saml:AuthnContextClassRef>urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport</saml:AuthnContextClassRef>
    </saml:AuthnContext></saml:AuthnStatement>
  </saml:Assertion></samlp:Response>

受保护的内容由三个细节决定:

  • URI="#_cf90…" 是同文档引用:它选中 ID 属性为该值的元素及其所有后代(XMLDSig §4.4.3.2)。该元素位于文档中的什么位置,不属于签名的内容。
  • enveloped-signature 变换在计算摘要之前移除 ds:Signature 元素本身,因此签名可以位于它所签名的内容之中。
  • 排他规范化(xml-exc-c14n#,此处为不含注释的版本)把选中的元素转换为规范字节串:属性顺序、命名空间声明和标签内的空白都会被规范化,注释会被删除。因此,不同的文档可能具有相同的规范形式。

验证过程先做“引用验证”(重新计算每个摘要),再对规范化后的 SignedInfo 做“签名验证”(XMLDSig §3.2)。它只回答一个问题:这些规范字节是由这个密钥签名的。对于文档的其余部分,它什么也没有说明。规范把这一点写进了一节的标题“Only What is Signed is Secure”(只有已签名的内容才是安全的,§8.1.1),并补充说,应用程序应当处理“经过变换(包括规范化)并已签名”的数据(§8.1.3)。

SAML 规范的要求

SAML 对 XMLDSig 做了进一步限制(SAML Core 2.0 §5.4):签名必须(MUST)是封装式(enveloped)签名;签名必须(MUST)包含“单个 <ds:Reference>,其中含有指向被签名断言或协议消息根元素 ID 属性值的同文档引用”;应当(SHOULD)使用排他规范化;除 enveloped-signature 和排他规范化以外的变换不应(SHOULD NOT)出现。接受其他变换的验证方“必须(MUST)确保 SAML 消息的任何内容都没有被排除在签名之外”。

Web 浏览器 SSO 配置文件(profile,SAML Profiles 2.0 §4.1.4)补充了几项检查,让有效签名真正意味着“这个用户、针对这个服务、就在此刻”:

  • 使用 HTTP-POST 绑定时,“所附带的断言必须(MUST)签名”(§4.1.4.5)。已签名的 Response 包裹着未签名的断言,并不满足这一要求。
  • 服务提供商必须(MUST)验证所有签名,并验证:bearer 类型 SubjectConfirmationData 中的 Recipient“与断言消费者服务 URL 匹配”;其 NotOnOrAfter“在允许的时钟偏差范围内尚未过去”;InResponseTo 等于服务提供商所发出的 AuthnRequest 的 ID,对于未经请求(unsolicited)的响应则不出现(§4.1.4.3)。
  • 在断言可能仍然有效的整个期间,服务提供商必须(MUST)“通过维护已使用 ID 值的集合,确保 bearer 断言不被重放”(§4.1.4.5)。
  • AudienceRestriction 条件“当且仅当 SAML 依赖方属于所指定受众中的一个或多个时,求值结果为 Valid”(SAML Core §2.5.1.4);Conditions 的 NotBefore 和 NotOnOrAfter 限定了断言的有效期(§2.5.1.2)。

只读取已签名数据的校验器

下面的校验器使用 signxml 5.2.0 处理 XML 签名。signxml 自己的文档把这条规则称为“See what is signed”(看清签了什么),并把它做进了 API:verify() 以 signed_xml 返回已签名的元素,签名未覆盖的注释已被移除;location 则固定了 ds:Signature 必须所在的位置。签名检查之后的所有操作都从 result.signed_xml 读取:

sp.py(signxml 5.2.0、lxml 6.1.3、cryptography 50.0.2)
# Service provider side: verify the signature, then read every value from the signed Assertion only.
import datetime

from cryptography import x509
from signxml import SignatureConfiguration, XMLVerifier

SAMLP = "urn:oasis:names:tc:SAML:2.0:protocol"
SAML = "urn:oasis:names:tc:SAML:2.0:assertion"
NS = {"samlp": SAMLP, "saml": SAML}
BEARER = "urn:oasis:names:tc:SAML:2.0:cm:bearer"


class SamlError(Exception):
    pass


def _time(value):
    return datetime.datetime.strptime(value, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=datetime.timezone.utc)


def validate_response(xml: bytes, *, idp_cert: x509.Certificate, idp_entity_id: str, sp_entity_id: str,
                      acs_url: str, request_id: str, seen_assertion_ids: set, skew=60, now=None):
    now = now or datetime.datetime.now(datetime.timezone.utc)
    lag = datetime.timedelta(seconds=skew)

    # 1. The signature: pinned IdP certificate from metadata (never the one in KeyInfo),
    #    exactly one Reference, ds:Signature where SAML puts it: inside the Assertion.
    config = SignatureConfiguration(location=f"./{{{SAML}}}Assertion/", expect_references=1)
    try:
        result = XMLVerifier().verify(xml, x509_cert=idp_cert, expect_config=config)
    except Exception as e:  # signxml raises InvalidSignature, InvalidInput and others
        raise SamlError(f"signature: {type(e).__name__}: {e}") from None

    # 2. From here on, only the element the signature covers. Never the document passed in.
    assertion = result.signed_xml
    if assertion.tag != f"{{{SAML}}}Assertion":
        raise SamlError(f"signed element is {assertion.tag}, not an Assertion")
    aid = assertion.get("ID")
    one = lambda path: assertion.find(path, NS)

    if (one("saml:Issuer").text or "").strip() != idp_entity_id:
        raise SamlError("issuer")
    conditions = one("saml:Conditions")
    if conditions is None:
        raise SamlError("no Conditions")
    if now + lag < _time(conditions.get("NotBefore")) or now - lag >= _time(conditions.get("NotOnOrAfter")):
        raise SamlError("assertion is outside its validity window")
    audiences = [a.text for a in conditions.findall("saml:AudienceRestriction/saml:Audience", NS)]
    if sp_entity_id not in audiences:
        raise SamlError(f"audience {audiences} is not this SP")

    # 3. A bearer SubjectConfirmation for this ACS URL, this request, still valid.
    data = one(f"saml:Subject/saml:SubjectConfirmation[@Method='{BEARER}']/saml:SubjectConfirmationData")
    if data is None:
        raise SamlError("no bearer SubjectConfirmationData")
    if data.get("Recipient") != acs_url:
        raise SamlError(f"recipient {data.get('Recipient')}")
    if now - lag >= _time(data.get("NotOnOrAfter")):
        raise SamlError("subject confirmation expired")
    if data.get("InResponseTo") != request_id:
        raise SamlError("InResponseTo does not match the AuthnRequest")

    # 4. One use per assertion ID, for as long as the assertion could be accepted.
    if aid in seen_assertion_ids:
        raise SamlError(f"assertion {aid} already used")
    seen_assertion_ids.add(aid)

    name_id = one("saml:Subject/saml:NameID")
    return {"name_id": "".join(name_id.itertext()), "assertion_id": aid}

作为对比,下面是这类缺陷的典型写法。它调用同样的签名检查,丢弃结果,然后读取在文档中找到的第一个 Assertion:

naive.py(反模式)
# Anti-pattern, for comparison only: the signature is checked, but the values come from the
# document as received instead of from the element the signature covers.
from lxml import etree
from signxml import SignatureConfiguration, XMLVerifier

NS = {"samlp": "urn:oasis:names:tc:SAML:2.0:protocol", "saml": "urn:oasis:names:tc:SAML:2.0:assertion"}


def naive_validate(xml: bytes, *, idp_cert, sp_entity_id: str, acs_url: str, request_id: str):
    config = SignatureConfiguration(location="./{urn:oasis:names:tc:SAML:2.0:assertion}Assertion/", expect_references=1)
    XMLVerifier().verify(xml, x509_cert=idp_cert, expect_config=config)  # result ignored
    doc = etree.fromstring(xml)
    assertion = doc.find("saml:Assertion", NS)  # the first Assertion in the document
    assert sp_entity_id in [a.text for a in assertion.findall(".//saml:Audience", NS)]
    data = assertion.find(".//saml:SubjectConfirmationData", NS)
    assert data.get("Recipient") == acs_url and data.get("InResponseTo") == request_id
    return {"name_id": assertion.find("saml:Subject/saml:NameID", NS).text}

用篡改过的响应实测

实验用身份提供商每次运行时生成一个 P-256 密钥和一张自签名证书,并签发已签名的响应;服务提供商固定使用该证书:

idp.py
# Lab identity provider: a key and self-signed certificate generated per run, and signed SAML Responses.
import datetime
import uuid

from cryptography import x509
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.x509.oid import NameOID
from lxml import etree
from signxml import XMLSigner
from signxml.algorithms import CanonicalizationMethod, SignatureMethod

SAMLP = "urn:oasis:names:tc:SAML:2.0:protocol"
SAML = "urn:oasis:names:tc:SAML:2.0:assertion"


def new_idp(name):
    key = ec.generate_private_key(ec.SECP256R1())
    subject = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, name)])
    now = datetime.datetime.now(datetime.timezone.utc)
    cert = (
        x509.CertificateBuilder()
        .subject_name(subject).issuer_name(subject).public_key(key.public_key())
        .serial_number(x509.random_serial_number())
        .not_valid_before(now - datetime.timedelta(minutes=5)).not_valid_after(now + datetime.timedelta(days=30))
        .sign(key, hashes.SHA256())
    )
    return key, cert


def iso(t):
    return t.strftime("%Y-%m-%dT%H:%M:%SZ")


def response(key, cert, *, name_id, audience, recipient, in_response_to, issuer="https://idp.example/saml",
             valid_for=300, now=None):
    """A Response with one signed Assertion: enveloped signature, exclusive C14N, Reference URI=#ID."""
    now = now or datetime.datetime.now(datetime.timezone.utc)
    until = now + datetime.timedelta(seconds=valid_for)
    aid = f"_{uuid.uuid4().hex}"
    xml = f"""<samlp:Response xmlns:samlp="{SAMLP}" xmlns:saml="{SAML}" ID="_{uuid.uuid4().hex}" Version="2.0"
  IssueInstant="{iso(now)}" Destination="{recipient}" InResponseTo="{in_response_to}">
  <saml:Issuer>{issuer}</saml:Issuer>
  <samlp:Status><samlp:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"/></samlp:Status>
  <saml:Assertion ID="{aid}" Version="2.0" IssueInstant="{iso(now)}">
    <saml:Issuer>{issuer}</saml:Issuer>
    <saml:Subject>
      <saml:NameID Format="urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress">{name_id}</saml:NameID>
      <saml:SubjectConfirmation Method="urn:oasis:names:tc:SAML:2.0:cm:bearer">
        <saml:SubjectConfirmationData NotOnOrAfter="{iso(until)}" Recipient="{recipient}" InResponseTo="{in_response_to}"/>
      </saml:SubjectConfirmation>
    </saml:Subject>
    <saml:Conditions NotBefore="{iso(now)}" NotOnOrAfter="{iso(until)}">
      <saml:AudienceRestriction><saml:Audience>{audience}</saml:Audience></saml:AudienceRestriction>
    </saml:Conditions>
    <saml:AuthnStatement AuthnInstant="{iso(now)}"><saml:AuthnContext>
      <saml:AuthnContextClassRef>urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport</saml:AuthnContextClassRef>
    </saml:AuthnContext></saml:AuthnStatement>
  </saml:Assertion>
</samlp:Response>"""
    root = etree.fromstring(xml.encode())
    assertion = root.find(f"{{{SAML}}}Assertion")
    signed = XMLSigner(
        signature_algorithm=SignatureMethod.ECDSA_SHA256,
        c14n_algorithm=CanonicalizationMethod.EXCLUSIVE_XML_CANONICALIZATION_1_0,
    ).sign(assertion, key=key, cert=[cert], reference_uri=aid)
    # SAML requires ds:Signature right after saml:Issuer inside the signed element.
    sig = signed.find("{http://www.w3.org/2000/09/xmldsig#}Signature")
    signed.remove(sig)
    signed.insert(1, sig)
    root.replace(assertion, signed)
    return etree.tostring(root)

驱动脚本把 10 个响应分别交给两个函数:1 个有效响应、1 个新的响应(先正常使用,随后被重放),以及 8 个经过篡改或不应被接受的响应:

run.py
# Lab driver: valid and manipulated SAML Responses against the validator (sp.py) and the anti-pattern (naive.py).
import copy
import datetime
import re

from lxml import etree

from idp import SAML, new_idp, response
from naive import naive_validate
from sp import SamlError, validate_response

key, cert = new_idp("idp.example signing")
other_key, other_cert = new_idp("idp.example signing")  # same name, different key: not the IdP
SP = {"sp_entity_id": "https://sp.example/saml", "acs_url": "https://sp.example/saml/acs", "request_id": "_req-7d1f"}
seen = set()


def issue(name_id="alice@example.com", **kw):
    args = {"audience": SP["sp_entity_id"], "recipient": SP["acs_url"], "in_response_to": SP["request_id"], **kw}
    return response(args.pop("key", key), args.pop("cert", cert), name_id=name_id, **args)


def check(label, xml, **kw):
    try:
        ours = validate_response(xml, idp_cert=cert, idp_entity_id="https://idp.example/saml",
                                 seen_assertion_ids=seen, **SP, **kw)
        ours = f"login as {ours['name_id']}"
    except SamlError as e:
        ours = f"rejected: {e}"
    try:
        naive = f"login as {naive_validate(xml, idp_cert=cert, **SP)['name_id']}"
    except Exception as e:
        naive = f"rejected: {type(e).__name__}"
    print(f"{label}\n  validator:    {ours}\n  anti-pattern: {naive}")


check("valid response", issue())

# Signature wrapping: an unsigned Assertion inserted before the signed one. The signature still
# verifies, because its Reference points by ID at the original Assertion.
doc = etree.fromstring(issue())
signed = doc.find(f"{{{SAML}}}Assertion")
forged = copy.deepcopy(signed)
forged.remove(forged.find("{http://www.w3.org/2000/09/xmldsig#}Signature"))
forged.set("ID", "_forged")
forged.find(f"{{{SAML}}}Subject/{{{SAML}}}NameID").text = "admin@example.com"
doc.insert(list(doc).index(signed), forged)
check("unsigned Assertion before the signed one", etree.tostring(doc))

# A comment inside the signed NameID: exclusive C14N without comments ignores it, so the
# signature stays valid; .text on the received document stops at the comment.
xml = issue("admin@example.com.evil.test")
xml = xml.replace(b"admin@example.com.evil.test<", b"admin@example.com<!---->.evil.test<")
check("comment inside the signed NameID", xml)

check("assertion for another SP", issue(audience="https://other-sp.example/saml"))
check("expired 10 minutes ago", issue(now=datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(minutes=15)))
replay = issue()
check("first use", replay)
check("same assertion again", replay)
tampered = issue().replace(b"alice@example.com", b"admin@example.com")
check("NameID edited after signing", tampered)
check("signed with another key + its cert", issue(key=other_key, cert=other_cert))
stripped = re.sub(rb"<ds:Signature.*?</ds:Signature>", b"", issue(), flags=re.S)
check("signature removed", stripped)
输出(python:3.14.8-slim-trixie)
valid response
  validator:    login as alice@example.com
  anti-pattern: login as alice@example.com
unsigned Assertion before the signed one
  validator:    login as alice@example.com
  anti-pattern: login as admin@example.com
comment inside the signed NameID
  validator:    login as admin@example.com.evil.test
  anti-pattern: login as admin@example.com
assertion for another SP
  validator:    rejected: audience ['https://other-sp.example/saml'] is not this SP
  anti-pattern: rejected: AssertionError
expired 10 minutes ago
  validator:    rejected: assertion is outside its validity window
  anti-pattern: login as alice@example.com
first use
  validator:    login as alice@example.com
  anti-pattern: login as alice@example.com
same assertion again
  validator:    rejected: assertion _2291663e82ca413bb791278fa3a9a3f8 already used
  anti-pattern: login as alice@example.com
NameID edited after signing
  validator:    rejected: signature: InvalidDigest: Digest mismatch for reference 0 (#_37ad00c1109f47e3b3b1b650a17aba70)
  anti-pattern: rejected: InvalidDigest
signed with another key + its cert
  validator:    rejected: signature: InvalidSignature: Signature verification failed: 
  anti-pattern: rejected: InvalidSignature
signature removed
  validator:    rejected: signature: InvalidInput: Expected to find XML element Signature in {urn:oasis:names:tc:SAML:2.0:protocol}Response using XPath ./{urn:oasis:names:tc:SAML:2.0:assertion}Assertion/ds:Signature
  anti-pattern: rejected: InvalidInput

逐个分析:

  • 已签名断言之前的未签名断言:签名仍然验证通过,因为它的引用按 ID 选中原始断言,无论该断言位于何处。反模式读取了文档中的第一个 Assertion,让一个身份提供商从未担保过的用户登录了。校验器使用 signed_xml,只看到了原始断言。这一类缺陷通常称为签名包装(signature wrapping)。
  • 已签名 NameID 中的注释:身份提供商签名的是 admin@example.com.evil.test,这是该提供商的用户完全可以合法拥有的账号。在 admin@example.com 之后插入 <!----> 不会改变规范形式,因为不含注释的排他规范化会删除注释,所以签名依然有效。在 lxml 中,.text 只返回第一个子节点之前的文本,因此反模式读到的是 admin@example.com。signxml 返回的已签名元素不含这条注释,而校验器会拼接所有文本节点,因此看到的是真实账号。正是这种模式在 Duo Labs 的研究之后于2018年2月以 CERT/CC VU#475445 公布,受影响的包括 OneLogin 的 python-saml 和 ruby-saml、saml2-js、OmniAuth-SAML 以及 Shibboleth 的 OpenSAML C++ 等。
  • 发给另一个服务提供商的断言虽然由同一个身份提供商签名,但没有通过受众检查。两种实现都拦下了它,因为反模式恰好检查了受众。
  • 过期和重放只有校验器拦下了。两者都是 SAML 规则,而不是签名规则:被截获的断言上的签名永远有效。
  • 被编辑、重新签名和未签名的响应在两种实现中都没有通过签名检查:分别是摘要不匹配;密钥不是固定的那个,尽管其证书嵌入在 KeyInfo 中且带有相同的主体名称;以及 location 要求有签名的位置没有签名。

决定实际部署成败的细节

签名响应还是签名断言:身份提供商可能对断言签名,也可能对响应签名,或者两者都签。如果只有 Response 已签名,断言作为其后代同样受到覆盖,此时规则变为:所用的断言必须是已签名响应元素的子元素。POST 配置文件仍然要求断言已签名,因此应配置身份提供商对断言签名。切勿接受“文档中某处有个签名”。

加密断言:EncryptedAssertion 必须先用你的密钥解密;解密后的 Assertion 还必须带有身份提供商的有效签名。加密只能证明发送方知道你的公钥。

信任来自元数据:身份提供商的签名证书应放在你的配置中,从其元数据导入,并在它轮换证书时更新;轮换期间新旧证书同时存在是正常的。消息中的证书什么也证明不了:任何人都能用任意主体名称创建证书,“another key”这一用例做的正是这件事。

算法:2005年的 SAML Core 文本列出的是 RSA 搭配 SHA-1;signxml 5.2.0 默认拒绝 SHA-1 签名方法和摘要方法,你也应该这样做。只接受你的身份提供商实际使用的方法,通常是 RSA-SHA256 或 ECDSA-SHA256。

XML 解析:在做任何其他处理之前,先在禁用外部实体和 DTD 的情况下解析;signxml 的解析器设置了 resolve_entities=False。拒绝有多个元素带有相同 ID 的文档。

时间:允许少量时钟偏差(几秒到一两分钟),并保存已使用的断言 ID,直到其 NotOnOrAfter 再加上该偏差为止。

合法响应验证失败时

相反的问题——来自你的身份提供商的真实响应无法通过验证——常见原因不多:

  • 身份提供商轮换了证书,而你的配置中仍是旧证书。从其元数据刷新;元数据通常会在新证书启用之前就把它列出来。
  • XML 被重新格式化过。排他规范化处理的是标记,而不是文本:在元素之间添加的缩进或换行会改变已签名元素内部的文本节点,导致摘要不匹配。验证时使用从表单字段解码出的原样字节;验证之前切勿美化输出、用其他库重新序列化或做任何“清理”。
  • 解码方式不对。使用 HTTP-POST 绑定时,SAMLResponse 表单字段是 XML 的 base64 编码,可能带有换行(SAML Bindings §3.5.4)。使用 HTTP-Redirect 时,消息经过 DEFLATE 压缩,其中的 XML 签名会被移除,另一个针对查询字符串的签名放在 SigAlg 和 Signature 参数中传递(§3.4.4.1);该签名针对 URL 编码后的参数验证,而不是针对 XML。携带已签名断言的响应根本不应使用 Redirect 绑定。
  • 签名不在校验器预期的位置。如果身份提供商对 Response 签名,而你的配置要求签名位于 Assertion 内部(或者反过来),验证就会失败。确定你要求哪一种,并让身份提供商的配置与之一致;不要把位置放宽为“任意位置”。
  • 时钟:刚登录就出现“尚未生效”,说明服务提供商的时钟慢了;只有在校正时钟之后,才可以放宽允许的偏差。

库

使用 SAML 库,而不是通用的 XML 签名库加上自己写的胶水代码;保持库为最新版本,并在其文档中找到返回已签名内容的函数。截至2026年10月20日的最新版本:

语言SAML 库XML 签名层
Pythonpython3-saml 1.16.0、pysaml2 7.5.5xmlsec 1.3.17、signxml 5.2.0
Node.js@node-saml/node-saml 5.1.0、samlify 2.15.0xml-crypto 6.4.0
Gocrewjam/saml v0.5.1russellhaering/goxmldsig v1.6.1

xml-crypto 的 README 写明了与 signxml 相同的规则:checkSignature() 成功后,使用 getSignedReferences() 返回的规范 XML,不要从原始文档中取节点;getReferences() 和 validateElementAgainstReferences() 在这一用途上已被弃用。如果你维护的代码先验证签名、再查询输入文档,要改的就是那一行。

检查清单

  1. 安全地解析:不允许 DTD、不允许外部实体、不允许重复的 ID。
  2. 用身份提供商元数据中固定的证书验证签名;判断信任时忽略 KeyInfo。
  3. 要求恰好一个 Reference,它指向你将使用的元素,变换只有 enveloped-signature 和排他规范化,并且只使用可接受的算法。
  4. 只从已签名元素中检查:Issuer 等于身份提供商的实体 ID;Conditions 的时间窗口,以及包含你的实体 ID 的 AudienceRestriction;bearer 类型的 SubjectConfirmationData,其 Recipient 为你的 ACS URL,NotOnOrAfter 是将来的时间,InResponseTo 为你的请求 ID(只有在你决定接受的未经请求的响应中才可以没有)。
  5. 记录断言 ID;如果它在仍然有效期间再次出现,予以拒绝。
  6. 从已签名元素中读取 NameID 和属性,拼接所有文本节点;按 Issuer 加 NameID 映射用户。
  7. 用过期的断言、重放的断言以及不属于身份提供商的证书进行测试:校验都必须失败。还要测试经过包装的响应和插入了注释的 NameID:登录的只能是身份提供商签名的那个身份,而且必须完整。

来源