PAYATHON 2026

ITfoxtec 注销时调用 Unbind 出现 Null Reference,如何处理?

排障赵师傅

非 MVC 的 VB .NET 4.8 项目可以调用 Unbind。这段代码需要先补上 samlRequest.Query,并修改用 Request.IsAuthenticated 判断注销阶段的逻辑。

手动创建的 ITfoxtec.Identity.Saml2.Http.HttpRequest 中,QueryString 和 Query 是两个独立属性。原代码只设置了 QueryString,Query 仍为 Nothing。Redirect binding 会访问 request.Query.AllKeys,这与当前的 NullReferenceException 堆栈吻合。不过,还需要根据已安装的 ITfoxtec 版本确认具体出错位置,因为目前没有提供版本号和异常对应的源码行号。请求类型定义、Redirect binding 实现

先修正请求构造

最小修改是同时设置解析后的查询集合和保留编码的查询字符串:

samlRequest.Query = HttpContext.Current.Request.QueryString
samlRequest.QueryString = HttpContext.Current.Request.Url.Query

这两个属性的用途不同:

属性用途
Query读取 SAMLResponse、RelayState、SigAlg、Signature 等参数
QueryString为 Redirect binding 的查询字符串签名验证保留编码信息

不要用 Request.QueryString.ToString() 重新生成验签字符串,也不要先做 URL 解码再重新编码。ITfoxtec 的 System.Web 适配器使用 request.Url.Query,Redirect binding 的验签代码会从中提取参与签名的参数。System.Web 适配器源码、查询字符串处理源码

原代码中的以下两处可以删除:

Using reader As New StreamReader(HttpContext.Current.Request.InputStream)
    samlRequest.Body = reader.ReadToEnd()
End Using

Dim samlXml As String = HttpContext.Current.Request.HttpMethod

这里接收的是 HTTP-Redirect 注销响应,消息在查询参数中,无须读取请求体。HttpMethod 返回的是 "GET" 等方法名,将它赋给 samlXml 并不能得到 SAML XML。

按消息类型区分注销流程

Request.IsAuthenticated 表示当前请求是否已通过应用的身份验证,无法据此判断请求是在发起注销,还是在接收注销响应。Microsoft API 文档

Entra ID 回调时,本地认证 Cookie 可能还在,原代码因此会再次执行 SendLogoutRequest()。未登录用户直接访问注销页面时,则可能进入 ReciveLogoutResponse(),尽管请求中根本没有 SAML 响应。

建议拆成两个入口:

  • Logout.aspx:处理用户发起的注销,发送 LogoutRequest。
  • LoggedOut.aspx:接收并验证 LogoutResponse,随后处理本地退出和页面跳转。

如果必须共用一个 URL,应先检查消息参数:

收到的参数处理方式
SAMLResponse按 Saml2LogoutResponse 处理
SAMLRequest按 LogoutRequest 处理,需要独立的单点注销逻辑
两者都没有不能调用 Unbind 解析注销响应

参数是否存在只能用来选择处理分支。即使 URL 中带有查询参数,也仍需验证消息,确认收到的是合法的 LogoutResponse。

Microsoft 文档说明,Entra ID 的 SAML 单点注销使用 HTTP-Redirect binding,即 HTTP GET。应用也应准备处理其他参与方触发的 LogoutRequest。Entra ID 单点注销协议

非 MVC 项目的请求处理示例

下面的方法可以放入现有的 AuthSAML 类。方法名沿用原代码,新增的 expectedRequestId 是应用自定义参数,用于核对响应是否对应之前发送的注销请求。

使用前需满足以下条件:

  • saml2Config 已初始化。
  • 配置中已加载可信 Entra ID 元数据提供的验签证书,并配置预期的 Issuer。
  • 调用方保存了实际发出的 LogoutRequest.ID。
  • 接收的是 HTTP-Redirect 注销响应。
Imports System
Imports System.Collections.Specialized
Imports System.Linq
Imports System.Security.Authentication
Imports System.Web
Imports ITfoxtec.Identity.Saml2

Public Function ReciveLogoutResponse(
    expectedRequestId As String
) As Saml2LogoutResponse

    Dim context = HttpContext.Current

    If context Is Nothing Then
        Throw New InvalidOperationException("当前没有 HTTP 请求上下文。")
    End If

    If saml2Config Is Nothing Then
        Throw New InvalidOperationException("SAML 配置尚未初始化。")
    End If

    If String.IsNullOrWhiteSpace(expectedRequestId) Then
        Throw New AuthenticationException("找不到待完成的注销请求。")
    End If

    Dim request = context.Request

    If Not String.Equals(
        request.HttpMethod, "GET", StringComparison.OrdinalIgnoreCase
    ) Then
        Throw New HttpException(400, "此端点只接收 HTTP-Redirect 注销响应。")
    End If

    Dim responseValues = request.QueryString.GetValues("SAMLResponse")

    If responseValues Is Nothing OrElse
       responseValues.Length <> 1 OrElse
       String.IsNullOrWhiteSpace(responseValues(0)) Then

        Throw New HttpException(400, "缺少唯一且非空的 SAMLResponse。")
    End If

    If request.QueryString.GetValues("SAMLRequest") IsNot Nothing Then
        Throw New HttpException(400, "此端点不处理 SAMLRequest。")
    End If

    If saml2Config.SignatureValidationCertificates Is Nothing OrElse
       Not saml2Config.SignatureValidationCertificates.Any() Then

        Throw New InvalidOperationException("尚未配置可信的 SAML 验签证书。")
    End If

    Dim binding As New Saml2RedirectBinding()

    Dim samlRequest As New ITfoxtec.Identity.Saml2.Http.HttpRequest With {
        .Method = request.HttpMethod,
        .QueryString = request.Url.Query,
        .Query = request.QueryString,
        .Form = New NameValueCollection(),
        .Binding = binding
    }

    Dim logoutResponse As New Saml2LogoutResponse(saml2Config)

    binding.Unbind(samlRequest, logoutResponse)

    If logoutResponse.InResponseTo Is Nothing OrElse
       Not String.Equals(
           logoutResponse.InResponseToAsString,
           expectedRequestId,
           StringComparison.Ordinal
       ) Then

        Throw New AuthenticationException("注销响应与发出的请求不匹配。")
    End If

    Return logoutResponse
End Function

设置 .Binding 是为了补全请求对象。直接调用 binding.Unbind(...) 时,使用的是局部变量 binding;原代码中可能直接导致空引用的主要问题是缺少 .Query。

Unbind 没有抛出异常后,仍需检查响应的 Status,才能判断注销是否成功。当前源码会读取状态码,但通用的 Saml2Response.ValidateStatus() 不会将所有非成功状态都转换为异常。响应状态处理源码

在回调页面中使用

可以用 ASP.NET Session 保存待完成的请求 ID。PendingSamlLogoutRequestId 是应用自定义的键,不属于 ITfoxtec API。

在 SendLogoutRequest() 中,取得实际将发送的 Saml2LogoutRequest 后,先保存它的 ID:

HttpContext.Current.Session("PendingSamlLogoutRequestId") =
    logoutRequest.IdAsString

如果发起注销时会执行 Session.Abandon(),应改用设有过期时间、能关联到当前注销流程的服务端存储。多标签页并行注销时,还需要支持多个待完成请求。

LoggedOut.aspx 的 Page_Load 可以这样处理:

Imports System.IdentityModel.Services
Imports System.Security.Authentication
Imports ITfoxtec.Identity.Saml2.Schemas

Protected Sub Page_Load(
    sender As Object, e As EventArgs
) Handles Me.Load

    Dim expectedRequestId =
        TryCast(Session("PendingSamlLogoutRequestId"), String)

    Dim logoutResponse =
        aSAML.ReciveLogoutResponse(expectedRequestId)

    Session.Remove("PendingSamlLogoutRequestId")

    If logoutResponse.Status <> Saml2StatusCodes.Success Then
        Throw New AuthenticationException(
            "SAML 注销失败,状态码:" & logoutResponse.Status.ToString()
        )
    End If

    Dim sam = FederatedAuthentication.SessionAuthenticationModule

    If sam Is Nothing Then
        Throw New InvalidOperationException(
            "应用未启用 SessionAuthenticationModule。"
        )
    End If

    sam.DeleteSessionTokenCookie()
    sam.SignOut()

    Response.Redirect("~/Login.aspx", False)
    Context.ApplicationInstance.CompleteRequest()
    Return
End Sub

本地退出代码沿用了原项目的 WIF SessionAuthenticationModule。如果混合架构中还使用了 Forms Authentication、OWIN Cookie 或应用自定义会话,应按各自的认证机制执行退出,不能假定这两次调用会清除所有会话。

回调端点还需允许未认证请求访问,避免认证模块提前将请求重定向到登录页。

如何使用参考示例中的适配器

如果项目已引用提供 System.Web 扩展方法的 ITfoxtec.Identity.Saml2.Mvc 程序集,且安装版本支持该签名,可以在 Web Forms 中包装请求后调用:

Imports System.Web
Imports ITfoxtec.Identity.Saml2
Imports ITfoxtec.Identity.Saml2.Mvc

Dim wrappedRequest As New HttpRequestWrapper(
    HttpContext.Current.Request
)

Dim samlRequest =
    wrappedRequest.ToGenericHttpRequest(validate:=True)

Dim logoutResponse As New Saml2LogoutResponse(saml2Config)

samlRequest.Binding.Unbind(samlRequest, logoutResponse)

这个扩展方法接收 HttpRequestBase,调用代码无须放在 Controller 中。它会填写查询集合和查询字符串,并选择 binding。MvcCore 中面向 ASP.NET Core 的扩展不能直接用于 System.Web 请求。System.Web 扩展方法源码

在所核对的适配器实现中,validate:=True 用于检查请求长度,不能替代 Unbind 的协议和签名处理。使用适配器后,仍需核对 InResponseTo、检查 Status,再处理本地会话。

为什么异常和日志看起来“时有时无”

现有代码不足以证明发生了异步调用,需要先检查实际执行路径。

用户点击注销、Entra ID 回调、浏览器刷新,以及未登录用户直接访问页面,都会进入 Page_Load,但每次收到的请求可能不同。原来的 IsAuthenticated 分支会让这些请求执行不同方法。

另外,原方法捕获异常后没有重新抛出:

Catch ex As Exception
    modulo_local.log_error_Guardar(...)
End Try

调用方因此无法得知 Unbind 已失败,仍会继续执行 Response.Redirect("~/Login.aspx")。调试时应记录 ex.ToString(),再用 Throw 重新抛出,保留原异常堆栈。生产环境则应交给统一错误处理,返回明确的失败页面。

ASP.NET 的 Response.Redirect(url) 默认还可能通过 Response.End() 引发 ThreadAbortException,需要与当前的 NullReferenceException 分开排查。示例使用 Response.Redirect(url, False),随后调用 CompleteRequest() 并显式 Return,因为 CompleteRequest() 不会自动退出当前方法。Microsoft 排查说明

写入 txt 的日志函数还需要检查以下问题:

  • 日志语句是否只在未执行的分支或 Catch 中。
  • IIS 应用池身份是否有目录写权限。
  • 多个请求或工作进程是否同时写入同一文件,导致文件占用。
  • 写入是否使用了缓冲却没有及时刷新,或日志函数内部是否吞掉了异常。
  • 日志函数是否再次抛出异常,覆盖了原来的 SAML 错误。

建议为每个 HTTP 请求生成关联 ID,记录请求方法、路径、IsAuthenticated、是否存在 SAMLResponse、是否进入 Unbind,以及完整异常。不要将整个查询字符串、完整 SAML 消息或认证 Cookie 直接写入普通日志。

修复后还需要确认的配置

补齐 Query 解决了请求对象的构造问题。如果之后出现“缺少 Signature”“签名无效”或 Issuer 不匹配,应分别检查可信元数据、证书轮换、实际回调参数及配置,不要靠清空验签证书集合让调用通过。

Entra ID 注销响应的 Issuer 不能直接照搬登录响应的值。Microsoft 给出的注销响应格式使用 https://login.microsoftonline.com/<TenantIdGUID>/,并将 InResponseTo 设置为对应注销请求的 ID。配置时应结合实际租户元数据和回调进行验证。Entra ID 注销响应说明

注销失败后是否仍清除本地会话,由应用制定明确策略。日志和页面必须区分“本地会话已清除”与“Entra ID 注销已验证成功”。原代码注释掉 Unbind 后,只执行了本地退出,无法确认远端注销响应是否有效。

备注:内容仅供参考。