简介:面向ASP.NET开发人员的SAML2身份验证服务资源,基于Sustainsys.Saml2库(原Kentor.AuthServices)为网站添加SAML2P支持,使应用可作为SAML2服务提供商(SP)接入统一身份认证。压缩包共883个文件、大小5.64MB,其中469个C#源文件构成核心实现,此外包含JavaScript脚本、Razor视图、CSS样式、配置文件及测试证书,覆盖完整工程结构。目前已有525人学习/下载。内容针对Sustainsys.Saml2的三个活动分支展开:v1依赖System.IdentityModel但仅安全支持,v2基于Microsoft.IdentityModel并多目标支持HttpModule、Mvc、Owin与AspNetCore2,开发分支则面向.Net Core未来v3。开发者可借此对比不同分支的API与配置差异,快速搭建SP集成环境,并参考示例处理证书、断言等关键环节,适合有SAML2接入或二次开发需求的.NET工程师。
1. 为什么 ASP.NET 的 SAML2 集成总在握手阶段翻车:先搞清身份提供商与依赖方的信任模型
上周接手一个企业信息管理系统,门户那边只给了两行地址和一个证书文件,要求全部子系统用 SAML2 对接统一身份认证。原以为签名验签是最容易出问题的,结果头三天全耗在 AuthnRequest 的 URL 构造上——用某个第三方库生成的请求,IdP 那边要么解不出 XML,要么提示签名算法不匹配。后来把这套服务里的代码一行行对着协议文档走了一遍,才发现 Deflate 压缩和签名串拼接顺序都必须严格按 Redirect 绑定规范来,差一个换行符都会翻车。
SAML2 本质上就是浏览器在两个站点之间搬运经 XML 签名的纸条:SP 把 AuthnRequest 压缩签名后交给 IdP,IdP 验证用户身份后把带签名的断言通过 POST 塞回 SP。这套 ASP.NET 的 SAML2 身份验证服务要做的事,正是把这两段路——请求构造、响应解析,连同元数据交换和自测脚本——做成可直接运行的代码骨架,少踩那些玄学签名坑。适合正在对接企业 IdP 的 .NET 开发者,以及想给自家系统加一个标准 SP 入口的团队。
2. SAML2 核心流程:从 AuthnRequest 构造到断言解析的代码骨架
2.1 重定向绑定:把 AuthnRequest 压缩、编码、签名后拼进 URL
SP 把用户送去 IdP 最常见的方式是 HTTP-Redirect 绑定。浏览器收到一个 302,跳到 IdP 的登录地址,URL 上带SAMLRequest和RelayState两个查询参数。SAMLRequest就是 XML 格式的 AuthnRequest,但 XML 直接塞进 URL 会被代理服务器重写换行和空格,加上 URL 长度受限,标准规定必须先做 Deflate 压缩再做 Base64 编码。这里的 Deflate 是原始 DEFLATE 流(RFC 1951),不是带 gzip 头部的压缩格式。很多新手在这一步直接用GZipStream,结果 IdP 解析出来的 XML 是乱码,签名验证永远失败。
第一步先把 AuthnRequest 的 XML 拼出来。下面这段代码是这套服务里最核心的生成逻辑,可以直接复用到你的工具类:
public string BuildAuthnRequestRedirectUrl(string idpSsoUrl, string entityId, string acsUrl, string relayState, X509Certificate2 signingCert) { var requestId = "_" + Guid.NewGuid().ToString("N"); var issueInstant = DateTime.UtcNow; var xml = $@"<samlp:AuthnRequest xmlns:samlp=""urn:oasis:names:tc:SAML:2.0:protocol"" xmlns:saml=""urn:oasis:names:tc:SAML:2.0:assertion"" ID=""{requestId}"" Version=""2.0"" IssueInstant=""{issueInstant:yyyy-MM-ddTHH:mm:ssZ}"" Destination=""{idpSsoUrl}"" ForceAuthn=""false"" IsPassive=""false"" AssertionConsumerServiceURL=""{acsUrl}""> <saml:Issuer>{entityId}</saml:Issuer> <samlp:NameIDPolicy AllowCreate=""true"" Format=""urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress""/> </samlp:AuthnRequest>"; // Redirect 绑定规定:XML 先做 Deflate 压缩再 Base64,不能套 gzip var compressed = DeflateCompress(Encoding.UTF8.GetBytes(xml)); var samlRequest = Convert.ToBase64String(compressed); // 签名串要严格用 SAMLRequest + RelayState 两个参数的原始拼接文本 var queryString = $"SAMLRequest={Uri.EscapeDataString(samlRequest)}" + $"&RelayState={Uri.EscapeDataString(relayState)}"; var signature = Convert.ToBase64String(SignData(queryString, signingCert)); return $"{idpSsoUrl}?{queryString}&Signature={Uri.EscapeDataString(signature)}"; }这里每次调用都会生成全新的requestId,前缀带下划线是常见做法,IdP 端会把ID当作防重放依据,同一个requestId出现两次,后者会被拒绝。IssueInstant必须用DateTime.UtcNow,格式化字符串里的Z表示 UTC 时区,漏掉的话某些 IdP 会按本地时间解析,断言时间窗口直接错位。
DeflateCompress和SignData两个方法在资源包的工具类里已经实现,核心分别是DeflateStream和 RSA 私钥签名,签名算法默认用 RSA-SHA256。签名对象是整个查询字符串,但不包含Signature参数本身,顺序也必须是SAMLRequest在前、RelayState在后。有些 IdP 对顺序不敏感,但严格实现的 IdP 会直接拒绝。
顺手列一下 AuthnRequest 里这几个参数的作用,后面排查问题会反复用到:
| 参数 | 取值示例 | 影响 |
|---|---|---|
ForceAuthn | false | 为 true 时强制 IdP 重新认证,适合高风险操作 |
IsPassive | false | 为 true 时 IdP 不显示登录页,无会话直接返回错误 |
NameIDPolicy.Format | emailAddress / persistent | 决定 IdP 返回的 NameID 格式,两边必须对齐 |
AssertionConsumerServiceURL | 回调地址 | 必须是 IdP 元数据里注册过的 ACS 地址 |
2.2 断言消费服务:接收 POST 回传并解析 SAMLResponse
用户从 IdP 登录成功后,浏览器会被带回 SP 的 ACS 地址,这次用的是 HTTP-POST 绑定。表单里带着SAMLResponse和RelayState。SAMLResponse同样是 Base64 编码,但这次不压缩。很多人在这一步踩坑:以为响应也走 Deflate,解压之后拿到一堆无法解析的字节。
ACS 端点的处理逻辑可以分成四段:解码、验签、校验条件、建立会话。下面是一个精简但能跑通的版本:
[HttpPost("acs")] public async Task<IActionResult> Acs() { var encoded = Request.Form["SAMLResponse"].ToString(); var relayState = Request.Form["RelayState"].ToString(); var xml = Encoding.UTF8.GetString(Convert.FromBase64String(encoded)); var doc = XDocument.Parse(xml); XNamespace samlp = "urn:oasis:names:tc:SAML:2.0:protocol"; XNamespace saml = "urn:oasis:names:tc:SAML:2.0:assertion"; XNamespace ds = "http://www.w3.org/2000/09/xmldsig#"; // 1. 先定位断言节点,再决定验签对象 var assertion = doc.Descendants(saml + "Assertion").FirstOrDefault(); if (assertion == null) return Unauthorized("Missing assertion"); var signedElement = assertion.Element(ds + "Signature") != null ? assertion : doc.Root; if (!ValidateXmlSignature(signedElement, _idpCertificate)) return Unauthorized("Signature verification failed"); // 2. 提取 NameID 与属性 var nameId = assertion.Descendants(saml + "NameID").FirstOrDefault()?.Value; if (string.IsNullOrEmpty(nameId)) return Unauthorized("Missing NameID"); // 3. 检查时间窗口和 AudienceRestriction,用 XmlConvert 避免本地时区偏移 var conditions = assertion.Element(saml + "Conditions"); var notBefore = XmlConvert.ToDateTime( conditions?.Attribute("NotBefore")?.Value ?? "0001-01-01T00:00:00Z", XmlDateTimeSerializationMode.Utc); var notOnOrAfter = XmlConvert.ToDateTime( conditions?.Attribute("NotOnOrAfter")?.Value ?? "9999-12-31T23:59:59Z", XmlDateTimeSerializationMode.Utc); if (DateTime.UtcNow < notBefore || DateTime.UtcNow > notOnOrAfter) return Unauthorized("Assertion outside validity window"); // 4. 建立认证票据并跳回原目标页 var identity = new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, nameId), new Claim("saml:relay", relayState ?? "") }, "SAML2"); await HttpContext.SignInAsync( CookieAuthenticationDefaults.AuthenticationScheme, new ClaimsPrincipal(identity), new AuthenticationProperties { IsPersistent = false }); return Redirect(relayState ?? "/"); }验签这一步注意选对节点:有些 IdP 只签Response,Assertion节点里没有Signature;有些反过来只在Assertion里签。代码里先判断断言内部有没有签名,有就验断言,没有就退回验Response。如果两边都有签名,优先验Response更稳妥,因为响应级签名同时覆盖了断言内容。
时间解析这里我特意用了XmlConvert.ToDateTime而不是DateTime.Parse。DateTime.Parse会把带Z后缀的字符串转成本地时间再转回来,生产服务器时区不是 UTC 的时候,很容易出现断言边缘时间差几分钟的情况。用XmlConvert配合XmlDateTimeSerializationMode.Utc,明确告诉解析器这是 UTC 时间,不做隐式转换。
另外别忘了InResponseTo。正常流程下,IdP 返回的Response会带一个InResponseTo属性,值就是你之前发给它的AuthnRequest的ID。这套服务里建议在消费断言前比对一次,防止攻击者拿别的请求的合法断言来顶替。资源包里的完整版有这个校验,但为了突出主链路,上面代码里先省略了。
3. 元数据配置与签名验证:把证书和信任端点装进正确的槽位
3.1 生成 SP 元数据:EntityID、ACS 与 KeyDescriptor 的取舍
IdP 不知道该往哪里回调、该用哪把证书验证 SP 的签名,全靠 SP 元数据。元数据是一份 XML,里面声明了三样东西:你是谁(EntityID)、你接收断言的地址(ACS)、你用来签名的证书公钥(KeyDescriptor)。把这文件交给 IdP 管理员导入,联调就成功了一半。
生成 SP 元数据的代码在资源包里是现成的,核心逻辑如下:
public string GenerateSpMetadata(string entityId, string acsUrl, string logoutUrl, X509Certificate2 cert) { var certB64 = Convert.ToBase64String(cert.RawData); return $@"<md:EntityDescriptor xmlns:md=""urn:oasis:names:tc:SAML:2.0:metadata"" entityID=""{entityId}""> <md:SPSSODescriptor AuthnRequestsSigned=""true"" WantAssertionsSigned=""true"" protocolSupportEnumeration=""urn:oasis:names:tc:SAML:2.0:protocol""> <md:KeyDescriptor use=""signing""> <ds:KeyInfo xmlns:ds=""http://www.w3.org/2000/09/xmldsig#""> <ds:X509Data><ds:X509Certificate>{certB64}</ds:X509Certificate></ds:X509Data> </ds:KeyInfo> </md:KeyDescriptor> <md:AssertionConsumerService index=""0"" isDefault=""true"" Location=""{acsUrl}"" Binding=""urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST""/> <md:SingleLogoutService Location=""{logoutUrl}"" Binding=""urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect""/> </md:SPSSODescriptor> </md:EntityDescriptor>"; }这里生成的是公钥证书的 Base64 编码,不是私钥。私钥永远不应该出现在元数据里,只放在 SP 服务器自己的证书存储中。Convert.ToBase64String(cert.RawData)取的是 DER 编码的证书原始字节,而不是带 PEM 头和尾的文本格式。某些 IdP 从元数据导入证书后再校验签名,底层比较的就是这段 Base64 解码后的二进制,格式错一点都对不上。
AuthnRequestsSigned="true"表示 SP 会对发出的每个 AuthnRequest 签名,WantAssertionsSigned="true"表示 SP 要求断言本身带签名。这两个属性建议都设为 true:如果WantAssertionsSigned不设置或设为 false,某些 IdP 可能只签名 Response 而不签断言,一旦代理层把断言内容替换掉,SP 端如果没有校验响应级签名就很容易被利用。
KeyDescriptor的use属性有signing和encryption两种用途。如果后续要做断言加密,需要再生成一个encryption用途的 KeyDescriptor。我一般会建议直接生成两个 KeyDescriptor,即便用的是同一把证书,也能避免某些严格实现的 IdP 因为找不到加密密钥而拒绝协商加密断言。这套服务里默认只做签名,不改协议的话不用关心加密密钥。
3.2 从 IdP 元数据提取端点与证书:解析 XML 时的三个易错点
对接真实 IdP 时,对方管理员通常也会给你一份 IdP 元数据 XML,或者直接给一个元数据 URL。里面包含登录端点、登出端点、签名证书。把这份 XML 安全地解析成配置,有三个易错点。
public IdpConfig LoadIdpMetadata(string metadataXml) { var doc = XDocument.Parse(metadataXml); XNamespace md = "urn:oasis:names:tc:SAML:2.0:metadata"; var sso = doc.Descendants(md + "SingleSignOnService") .FirstOrDefault(x => x.Attribute("Binding")?.Value.Contains("HTTP-Redirect") == true); var slo = doc.Descendants(md + "SingleLogoutService") .FirstOrDefault(x => x.Attribute("Binding")?.Value.Contains("HTTP-Redirect") == true); // X509Certificate 文本可能带换行和缩进,必须先清理再解码 var certB64 = doc.Descendants() .First(x => x.Name.LocalName == "X509Certificate").Value .Replace("\r", "").Replace("\n", "").Trim(); return new IdpConfig { SsoUrl = sso?.Attribute("Location")?.Value, SloUrl = slo?.Attribute("Location")?.Value, Certificate = new X509Certificate2(Convert.FromBase64String(certB64)) }; }第一个易错点是命名空间。md前缀只是示例,不同 IdP 导出的元数据可能用md、ns1或者根本没有前缀,但命名空间 URI 永远是urn:oasis:names:tc:SAML:2.0:metadata。用XNamespace md配合md + "SingleSignOnService"是按 URI 匹配,不会因为前缀不同而翻车。如果直接写.Element("md:SingleSignOnService"),那种写法定死了前缀,换一个 IdP 立刻抛空引用。
第二个易错点是证书文本的清理。元数据里的<ds:X509Certificate>内容通常是带换行的 Base64 字符串,有些 IdP 还会在中间插入缩进空格,直接Convert.FromBase64String会因为非法字符抛异常。先移除\r、\n再.Trim()是必须的。
第三个易错点是证书构造方式。X509Certificate2接收构造函数参数是 DER 编码的字节,而很多开发者电脑上的证书是.cer文件,里面带 PEM 头。如果你把 PEM 文本Convert.FromBase64String再传给构造器,证书对象能建出来但公钥对不上,签名验证自然失败。从 IdP 元数据里拿到的证书体是裸 Base64,正好对应cert.RawData的格式,记住这一点能少走很多弯路。
4. 避坑:SAML2 接入中五个高频故障与排查路径
4.1 现象:签名验证一直失败,证书明明是对的
我遇到过最典型的情况:IdP 元数据里导出的证书没问题,SP 端也导入了同一张证书,但每次 ACS 验签都返回失败。
原因有两个维度。第一是验签对象选错:Response和Assertion都有可能有独立签名,你把响应级签名的证书拿去验断言,或者反过来,结果自然对不上。第二是时间差:IdP 轮换了证书,但元数据缓存还是旧值,新签名的响应用的是一把新证书,SP 手里却还是旧公钥。
解决方法是先输出 XML 签名节点的KeyInfo,看证书指纹和你导入的是否一致。再检查CheckSignature时传入的节点是否与ReferenceURI指向的节点相同。代码里可以临时加一段日志:
var signedXml = new SignedXml(xmlDoc); signedXml.LoadXml(signatureElement); _logger.LogInformation("ReferenceURI: {0}", signedXml.SignedInfo.References[0].Uri);看到Uri之后,对比一下你实际验签的节点,通常能立刻定位问题。
4.2 现象:NameID 正确但用户始终进不了系统
断言能验签,NameID 也取到了,但登录后系统里找不到对应用户。
原因是NameIDPolicy.Format和 IdP 实际返回的NameID格式不一致。SP 请求了emailAddress,企业 IdP 可能配置成persistent,返回的 NameID 是 UUID 格式。SP 端拿 UUID 去用户库里匹配邮箱,自然匹配不到。
解决方法是把 NameID 提取做成兼容处理:先取原始值,再记录Format属性,然后按应用需要做映射。某些 IdP 还会用<EncryptedID>代替<NameID>,这时候要先解密才能拿明文。建议在 ACS 代码里同时处理三个分支:普通NameID、EncryptedID、以及NameID缺失时从 attribute statement 里的user.email这类属性回退。资源包里的完整 ACS 实现有这套回退逻辑,单跑模板代码时很容易忽略。
4.3 现象:SP 重定向到 IdP 后永远回不来
点了登录,浏览器跳到 IdP,输完账号密码,页面又跳回 SP,但停在错误页或空白页,一直没有最终落地。
最常见原因是 ACS 地址没注册。IdP 侧只认元数据里登记过的 ACS URL,如果你本地调试用https://localhost:5001/saml2/acs,而元数据里写的是线上地址,回调 URL 对不上,IdP 会直接报错。另一种情况是 ACS 端点没有匿名权限,请求被 ASP.NET 的认证中间件拦下来,带 Cookie 跳回登录页,形成循环。
解决方法是先确认两件事:第一,浏览器最终请求的 ACS 地址和元数据里的Location完全一致;第二,ACS 控制器或页面加了[AllowAnonymous]。如果是 ASP.NET Core 中间件方案,大概率是在注册中间件时把 ACS 路径排除在保护之外了。这类问题最直接的做法是看 IdP 返回的错误码,然后检查 IdP 侧记录的 SP 元数据快照。
4.4 现象:断言未生效或已过期
日志里出现Assertion outside validity window,但服务器时间看起来没问题。
原因通常是 IdP 和 SP 的服务器时钟存在偏差,尤其是虚拟化环境里宿主机时钟漂移会导致两台服务器差出几十秒甚至几分钟。SAML 规范允许一定时钟偏移,但具体是多少由 SP 实现决定。
解决方法是把时间窗口判断加一个AllowedClockSkew参数,不要用绝对时间硬比。参考实现:
private static readonly TimeSpan AllowedClockSkew = TimeSpan.FromMinutes(2); public bool IsAssertionValid(DateTime notBefore, DateTime notOnOrAfter, DateTime now) { return now >= notBefore - AllowedClockSkew && now <= notOnOrAfter + AllowedClockSkew; }同时检查一下notBefore和notOnOrAfter是不是真正的 UTC 时间。如果 IdP 返回的字符串不带时区后缀,而 SP 按 UTC 解析,两边会差出好几个小时。这类问题在跨时区的云 IdP 和本地机房里特别常见。
4.5 现象:登出请求被 IdP 拒绝
SP 发起的 Single Logout,IdP 返回Invalid signature,或者 IdP 主动发来的 LogoutRequest 在 SP 端验签失败。
原因多半是签名算法不一致。企业 IdP 可能只配置了 RSA-SHA256,而 SP 默认生成 AuthnRequest 时用的是旧版库默认的 RSA-SHA1,IdP 端严格校验算法引用,发现不匹配直接拒绝。
解决方法是显式指定SignedXml.SignatureMethod为http://www.w3.org/2001/04/xmldsig-more#rsa-sha256,同时把摘要算法指定为 SHA256。如果是 IdP 发来的 LogoutRequest 验签失败,先看它用的是哪个算法、哪把证书,再用对应思路处理。这套服务里所有签名入口都统一走一个SignXmlDocument方法,改算法只需要改一处配置,强烈建议你接入时也这么做,不然散落各处的签名代码排查起来会很痛苦。
5. 代码之外的接线:把 SAML2 服务落进 ASP.NET 管道与缓存
5.1 中间件注册与路由约束:ACS、元数据端点必须匿名放行
SAML2 服务的接入不只是几个工具方法,还要和 ASP.NET 的认证中间件配合。最常见的翻车点是你把认证中间件全局启用后,ACS 端点也被强制要求登录,结果整个流程形成了循环:用户被送到 IdP,IdP 把它送回 ACS,ACS 又要求用户认证,又送回 IdP。
我的做法是给 ACS 和元数据端点显式放行,再用中间件统一接管其他 SAML 动作。参考下面这段:
app.UseAuthentication(); app.UseAuthorization(); app.MapWhen( context => context.Request.Path.StartsWithSegments("/saml2/acs") || context.Request.Path.StartsWithSegments("/saml2/metadata") || context.Request.Path.StartsWithSegments("/saml2/slo"), branch => branch.Run(async ctx => { var samlService = ctx.RequestServices.GetRequiredService<Saml2Service>(); await samlService.HandleAsync(ctx); }));逻辑说明:MapWhen在管道里按路径分支,ACS、元数据、SLO 三个端点交给专门的Saml2Service处理,不走 Cookie 认证逻辑。其余路由照常走UseAuthentication。这样即使之后给整个站点加了[Authorize]全局过滤器,也不会影响 SAML 回调。
参数说明里有一个细节:StartsWithSegments("/saml2/acs")是前缀匹配,如果以后有/saml2/acs2之类的路径也会被接管,建议把三个路径常量单独抽出来统一管理。生产环境如果需要支持多个 IdP,路径里可以带上配置名,例如/saml2/{idp}/acs。
5.2 断言重放缓存:一次性消费的会话防重策略
SAML 规范要求每条断言只能消费一次,SP 端需要用缓存记下已经处理过的断言 ID。如果漏掉这一步,攻击者截获一份合法 SAMLResponse 后反复 POST 到 ACS,每次都能拿到新会话,等于把登录态暴露在重放风险里。
资源包里带了一个AssertionReplayCache类,单实例部署时用IMemoryCache就够了:
public class AssertionReplayCache { private readonly IMemoryCache _cache; public AssertionReplayCache(IMemoryCache cache) { _cache = cache; } public bool TryConsumeAssertion(string assertionId, DateTime notOnOrAfter) { if (_cache.TryGetValue(assertionId, out _)) return false; // 过期时间取断言剩余有效期,最多不超过 15 分钟 var expire = notOnOrAfter - DateTime.UtcNow; if (expire > TimeSpan.FromMinutes(15)) expire = TimeSpan.FromMinutes(15); _cache.Set(assertionId, true, expire); return true; } }逻辑说明:每次 ACS 消费断言前先调用TryConsumeAssertion,返回 false 就直接拒绝。assertionId用断言节点里的ID属性,这个 ID 在整个 IdP 内唯一。
参数说明:缓存过期时间不建议直接设成notOnOrAfter - now,因为有些 IdP 会把断言有效期设成一天甚至更长,但重放风险窗口根本不需要留存这么久。日常接入场景 15 分钟足够,也避免长时间占用内存。多实例部署时要把IMemoryCache换成 Redis 之类的共享缓存,否则 A 实例消费过的断言,B 实例不知道,攻击者在两个实例间轮询还是能重放成功。
5.3 登出闭环:SP 侧清理与 IdP 全局登出的成本取舍
SAML2 单点登出流程比较繁琐,因为 IdP 需要主动通知每一个已接入的 SP。如果你接入的子系统很多,SLO 请求会像广播一样同时打到所有服务,处理不当就会拖垮 IdP 或 SP。
SP 侧最基本的处理是响应 IdP 的 LogoutRequest:验签、清掉本地 Cookie、回一个 LogoutResponse。下面这段是这套服务里 SLO 端点的一种实现:
[HttpPost("slo")] public async Task<IActionResult> SingleLogout() { if (Request.Form.ContainsKey("SAMLRequest")) { var encoded = Request.Form["SAMLRequest"].ToString(); var xml = Encoding.UTF8.GetString(Convert.FromBase64String(encoded)); if (!ValidateLogoutRequestSignature(xml, _idpCertificate)) return Unauthorized("LogoutRequest signature invalid"); // 签名合法才清本地会话 await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme); } return Redirect("/home/logout-success"); }这个实现里我没返回签名的 LogoutResponse,只做了本地清理。从投入产出比看,如果 IdP 不强制要求 SP 回 LogoutResponse,这样也能完成主要目标:用户退出后,下次访问 SP 依然会被要求重新认证。
真正要权衡的是要不要做全局登出。企业里常见诉求是用户在 IdP 退出后,所有子系统全部退出。这需要 SP 暴露的 SLO 服务能正常处理 POST 或 Redirect 绑定,并且 IdP 侧配置好了每个 SP 的登出端点。这套服务里同时实现了 SP 发起的 SLO 和 IdP 发起的 SLO,但建议先跑通 IdP 发起的 SLO 再启用 SP 发起,后者会把登出责任全压到 SP 端,任何一个 SP 响应超时,整个链路都会等很久。
6. 本地模拟 IdP 自测:一条最小链路验证握手全流程
联调企业 IdP 往往要等对方开放测试环境,等待周期动辄两三天。我后来养成的习惯是先在本地起一个模拟 IdP,把整套 SAML2 握手自己跑通,再去连真实 IdP。这样至少能保证问题不会同时出现在两边。
模拟 IdP 的核心是接收 SP 的 AuthnRequest,解析出 ACS 地址,然后回一个已签名的 SAMLResponse。下面这个最小实现可以直接放在测试项目里:
[HttpGet("/mock-idp/sso")] public IActionResult Sso([FromQuery] string SAMLRequest, [FromQuery] string RelayState) { // SAMLRequest 是 Base64 的 Deflate 压缩数据,不要再次 UnescapeDataString var deflated = Convert.FromBase64String(SAMLRequest); using var input = new MemoryStream(deflated); using var deflate = new DeflateStream(input, CompressionMode.Decompress); using var reader = new StreamReader(deflate, Encoding.UTF8); var authnRequestXml = reader.ReadToEnd(); // 从 AuthnRequest 属性里取出回调地址 var acsUrl = Regex.Match(authnRequestXml, @"AssertionConsumerServiceURL=""([^""]+)""").Groups[1].Value; var responseXml = BuildMockSamlResponse(acsUrl, "mockuser@example.com"); var html = $@"<html><body> <form id=""f"" method=""post"" action=""{acsUrl}""> <input type=""hidden"" name=""SAMLResponse"" value=""{Convert.ToBase64String(Encoding.UTF8.GetBytes(responseXml))}"" /> <input type=""hidden"" name=""RelayState"" value=""{RelayState}"" /> </form> <script>document.getElementById('f').submit();</script> </body></html>"; return Content(html, "text/html; charset=utf-8"); }验证步骤按照这个顺序走,每一步都确认结果后再进下一步。
第一步,把 SP 的BuildAuthnRequestRedirectUrl里idpSsoUrl指到http://localhost:5000/mock-idp/sso。浏览器访问 SP 的登录入口,观察地址栏是否出现SAMLRequest和Signature参数。如果这里少了签名,说明SignData没拿到私钥。
第二步,模拟 IdP 自动提交表单后,浏览器会带着 SAMLResponse 跳回 ACS。如果 ACS 返回签名验证失败,优先检查模拟 IdP 里BuildMockSamlResponse用的证书是不是 SP 元数据里导入的那张公钥对应的私钥。这一步能过滤掉大部分证书配置错误。
第三步,确认 SP 登录成功并写入 Cookie。此时再手动打开刚才那条 SAMLResponse 的 POST 地址重复提交一次,观察是否触发重放保护。如果第二次还能登录成功,说明AssertionReplayCache没接进 ACS 处理链路。
从那以后,我每次接入新的 IdP,都会先把这套模拟 IdP 跑一遍,确认自己的 SP 逻辑没毛病,再去和对方联调。签名算法、时间格式、NameID 格式这些最容易扯皮的细节,在自测阶段就会暴露出来,而不是被对方一句"配置不对"打回来。希望帮到你。
本文还有配套的精品资源,点击获取