Azure AD Microsoft Identity Web OpenIdConnectEvents - 如何在注销期间从用户令牌访问可选声明

Azure AD Microsoft Identity Web OpenIdConnectEvents - How to access optional claims from the user token during sign out

将 Net Core 3.1 与 Microsoft Identity Web 和 Azure AD 结合使用。

我正在尝试为用户登录和退出我的 Web 应用程序项目设置一些日志记录。日志记录需要包括用户的详细信息以及他们在登录和注销期间使用的客户端端点的 IP 地址。然后,我通过一种扩展方法传递 IP 地址,以捕获添加到该用户身份验证的日志事件中的地理位置信息。

在 startup.cs 中,我为 OpenIdConnectOptions 配置了一些扩展选项,它们是:

我创建的 OpenIdEvents class 只是简单地从 startup.cs 文件中移除方法以保持清洁。

摘自下面的 startup.cs:

// Create a new instance of the class that stores the methods called
// by OpenIdConnectEvents(); i.e. when a user logs in or out the app.
// See section below :- 'services.Configure'
OpenIdEvents openIdEvents = new OpenIdEvents();

services.Configure<OpenIdConnectOptions>(OpenIdConnectDefaults.AuthenticationScheme, options =>
{
    // The claim in the Jwt token where App roles are available.
    options.TokenValidationParameters.RoleClaimType = "roles";
    // Advanced config - capturing user events. See OpenIdEvents class.
    options.Events ??= new OpenIdConnectEvents();
    options.Events.OnTokenValidated += openIdEvents.OnTokenValidatedFunc;
    // This is event is fired when the user is redirected to the MS Signout Page (before they've physically signed out)
    options.Events.OnRedirectToIdentityProviderForSignOut += openIdEvents.OnRedirectToIdentityProviderForSignOutFunc;
    // DO NOT DELETE - May use in the future.
    // OnSignedOutCallbackRedirect doesn't produce any claims to read for the user after they have signed out.
    options.Events.OnSignedOutCallbackRedirect += openIdEvents.OnSignedOutCallbackRedirectFunc;
 });
            

到目前为止,我已经找到了一个解决方案来捕获用户登录时所需的声明,传递给第一个方法的 'TokenValidatedContext' 'OnTokenValidatedFunc' 包含安全令牌的详细信息,其中本身显示了我配置的可选声明,包括 IP 地址(称为“ipaddr”)

其中一些可选声明是在 Azure 的应用程序清单文件中配置的,它们存在于第一种方法的安全令牌中,因此非常确定 Azure 设置正确。

从 Azure 应用程序清单文件中提取:

"optionalClaims": {
        "idToken": [
            {
                "name": "family_name",
                "source": null,
                "essential": false,
                "additionalProperties": []
            },
            {
                "name": "given_name",
                "source": null,
                "essential": false,
                "additionalProperties": []
            },
            {
                "name": "ipaddr",
                "source": null,
                "essential": false,
                "additionalProperties": []
            }
        ],
        "accessToken": [],
        "saml2Token": []
    },

'OnTokenValidatedFunc'方法如下图:

        /// <summary>
        /// Invoked when an IdToken has been validated and produced an AuthenticationTicket.
        /// See weblink: https://docs.microsoft.com/en-us/dotnet/api/microsoft.aspnetcore.authentication.openidconnect.openidconnectevents.ontokenvalidated?view=aspnetcore-3.0
        /// </summary>
        /// <param name="context"></param>
        /// <returns></returns>
        public async Task OnTokenValidatedFunc(TokenValidatedContext context)
        {
            var token = context.SecurityToken;
            var userId = token.Claims.First(claim => claim.Type == "oid").Value;
            var givenName = token.Claims.First(claim => claim.Type == "given_name").Value;
            var familyName = token.Claims.First(claim => claim.Type == "family_name").Value;
            var userName = token.Claims.First(claim => claim.Type == "preferred_username").Value;
            string ipAddress = token.Claims.First(claim => claim.Type == "ipaddr").Value;

            GeoHelper geoHelper = new GeoHelper();
            var geoInfo = await geoHelper.GetGeoInfo(ipAddress);

            string logEventCategory = "Open Id Connect";
            string logEventType = "User Login";
            string logEventSource = "WebApp_RAZOR";
            string logCountry = "";
            string logRegionName = "";
            string logCity = "";
            string logZip = "";
            string logLatitude = "";
            string logLongitude = "";
            string logIsp = "";
            string logMobile = "";
            string logUserId = userId;
            string logUserName = userName;
            string logForename = givenName;
            string logSurname = familyName;
            string logData = "User login";

            if (geoInfo != null)
            {
                logCountry = geoInfo.Country;
                logRegionName = geoInfo.RegionName;
                logCity = geoInfo.City;
                logZip = geoInfo.Zip;
                logLatitude = geoInfo.Latitude.ToString();
                logLongitude = geoInfo.Longitude.ToString();
                logIsp = geoInfo.Isp;
                logMobile = geoInfo.Mobile.ToString();
            }

            // Tested on 31/08/2020
            Log.Information(
                "{@LogEventCategory}" +
                "{@LogEventType}" +
                "{@LogEventSource}" +
                "{@LogCountry}" +
                "{@LogRegion}" +
                "{@LogCity}" +
                "{@LogZip}" +
                "{@LogLatitude}" +
                "{@LogLongitude}" +
                "{@LogIsp}" +
                "{@LogMobile}" +
                "{@LogUserId}" +
                "{@LogUsername}" +
                "{@LogForename}" +
                "{@LogSurname}" +
                "{@LogData}",
                logEventCategory,
                logEventType,
                logEventSource,
                logCountry,
                logRegionName,
                logCity,
                logZip,
                logLatitude,
                logLongitude,
                logIsp,
                logMobile,
                logUserId,
                logUserName,
                logForename,
                logSurname,
                logData);

            await Task.CompletedTask.ConfigureAwait(false);
        }

查看下面的调试截图:

展开声明时,您可以看到显示“ipaddr”的声明:

我的问题:

当用户注销时从 OpenIdConnectEvents 触发的其他事件类型不以相同的方式运行,这就是我被卡住的地方!

我尝试测试了两种不同的事件类型:

在用户注销过程中,每一个都在略有不同的点被触发,即当用户被重定向到 Microsoft 注销页面时,就在他们实际点击之前,'OnRedirectToIdentityProviderForSignOutFunc' 被触发按钮并退出。

这不是一个理想的事件类型,因为用户可以中止退出应用程序并且生成的日志不会反映这一点,但是到目前为止我发现我至少可以访问大部分声明用户的,但未列出“ipaddr”声明,我根本不知道为什么或如何获得它。

当我查看调试信息时,我发现安全令牌根本没有显示,访问用户声明的唯一方法是通过导航到 context.HttpContext.User.Claims[=17 来读取上下文的另一部分=]

调试截图:

如下所示的方法:

        public async Task OnRedirectToIdentityProviderForSignOutFunc(RedirectContext context)
        {
            var user = context.HttpContext.User;
            string ipAddress = user.Claims.FirstOrDefault(claim => claim.Type == "ipaddr").Value;
            var userId = user.FindFirst("http://schemas.microsoft.com/identity/claims/objectidentifier").Value;
            var givenName = user.Claims.FirstOrDefault(c => c.Type == ClaimTypes.GivenName)?.Value;
            var familyName = user.Claims.FirstOrDefault(c => c.Type == ClaimTypes.Surname)?.Value;
            var userName = user.Claims.FirstOrDefault(c => c.Type == ClaimTypes.Name)?.Value;
            
            // The IP Address claim is missing!
            //string ipAddress = claims.First(claim => claim.Type == "ipaddr").Value;

            await Task.CompletedTask.ConfigureAwait(false);
        }

上述方法只给出了部分解决方案,因为我仍然需要 IP 地址声明但根本不存在,但是如上所述使用此事件类型的选择无论如何都不理想。

最后:

到目前为止,尝试订阅最后一个选项 'OnSignedOutCallbackRedirect' 完全是浪费时间,因为 none 的用户声明在上下文中完全存在。一旦用户点击退出按钮并返回到 Web 应用程序中的 'Signed Out' 页面,Microsoft 似乎就会将它们转储。

我真的想要一个解决方案,解决用户实际退出时的问题,而不是在退出过程中进行到一半,但我必须能够访问用户声明,包括 IP 地址,这在任何一个中都不存在以上两个事件在此过程中触发。

我只想捕获用户的详细信息(声明)和他们连接的客户端会话的 IP 地址,并在他们登录和注销 Web 应用程序时记录这些信息。这个要求真的过分吗!

关于这方面的文档非常少,我非常感谢任何了解 MS Identity Web 和 OpenIDConnect Events 在幕后如何运作的人提供的一些线索。

解决方案 1 = 能够在 'OnRedirectToIdentityProviderForSignOut' 期间从上下文中访问 IP 地址声明,但它当前丢失...

解决方案 2(首选)= 能够在 'OnSignedOutCallbackRedirect' 期间访问用户声明,但目前 none 已全部列出。

提前致谢...

I need to be able to access the claims from the user once they have signed out of the application using one of two possible events that are generated from OpenIdConnect

用户此时退出。 He/She 不再存在,所以它没有声明是有道理的,它会返回默认的空匿名用户,因为它已注销。

正如我在上面的评论中所提到的,并且考虑到 Jean-Marc Prieur 的评论,即一旦用户完全注销就不会出现任何声明,我最终只是抓住了细节通过 OnRedirectToIdentityProviderForSignOutFunc 方法的用户上下文,然后使用单独的 GeoHelper class 来获取该人唱歌时所在目的地的 IP 地址(或者我们应该说即将退出!

是的,这不是最理想的原因和影响,但老实说,这对我来说不会是一个大问题,对大多数情况下的其他人来说可能不会,当有人注销时登录不是生意关键,更多的是了解系统使用情况。当有人到达 MS 弹出页面注销时,我们应该假设 99% 的情况他们会继续并实际注销。

所以下面是我用来实现上述场景的代码:

startup.cs class(摘自 startup.cs 代码膨胀)

// Create a new instance of the class that stores the methods called
// by OpenIdConnectEvents(); i.e. when a user logs in or out the app.
// See section below :- 'services.Configure'
OpenIdEvents openIdEvents = new OpenIdEvents();

// The following lines code instruct the asp.net core middleware to use the data in the "roles" claim in the Authorize attribute and User.IsInrole()
// See https://docs.microsoft.com/aspnet/core/security/authorization/roles?view=aspnetcore-2.2 for more info.

services.Configure<OpenIdConnectOptions>(OpenIdConnectDefaults.AuthenticationScheme, options =>
{
    // The claim in the Jwt token where App roles are available.
    options.TokenValidationParameters.RoleClaimType = "roles";
    // Advanced config - capturing user events. See OpenIdEvents class.
    options.Events ??= new OpenIdConnectEvents();
    options.Events.OnTokenValidated += openIdEvents.OnTokenValidatedFunc;
    // This is event is fired when the user is redirected to the MS Signout Page (before they've physically signed out)
    options.Events.OnRedirectToIdentityProviderForSignOut += openIdEvents.OnRedirectToIdentityProviderForSignOutFunc;
    // DO NOT DELETE - May use in the future.
    // OnSignedOutCallbackRedirect doesn't produce any user claims to read from for the user after they have signed out.
    options.Events.OnSignedOutCallbackRedirect += openIdEvents.OnSignedOutCallbackRedirectFunc;
});

我的自定义 class 地理位置:

namespace MyProject.Classes.GeoLocation
{
    /// <summary>
    /// See weblink for API documentation: https://ip-api.com/docs or https://ip-api.com/docs/api:json
    /// Note: Not free for commercial use - fee plan during development only!
    /// Sample query: http://ip-api.com/json/{ip_address}?fields=status,message,country,countryCode,region,regionName,city,zip,lat,lon,isp,mobile,query
    /// </summary>
    public class GeoHelper
    {
        private readonly HttpClient _httpClient;

        public GeoHelper()
        {
            _httpClient = new HttpClient()
            {
                Timeout = TimeSpan.FromSeconds(5)
            };
        }

        public async Task<GeoInfo> GetGeoInfo(string ip)
        {
            try
            {
                var response = await _httpClient.GetAsync($"http://ip-api.com/json/{ip}?fields=status,message,country,countryCode,region,regionName,city,zip,lat,lon,isp,mobile,query");

                if (response.IsSuccessStatusCode)
                {
                    var json = await response.Content.ReadAsStringAsync();

                    return JsonConvert.DeserializeObject<GeoInfo>(json);
                }
            }
            catch (Exception)
            {
                // Do nothing, just return null.
            }

            return null;
        }
    }
}

OpenIdEvents.cs class:

namespace MyProject.Classes.Security
{
    public class OpenIdEvents
    {
        // Create the concurrent dictionary to store the user's IP Addresss when they sign in, the value is fetched
        // from the dictionary when they sing out. given this information is not present within the contect passed through the event.
        private readonly ConcurrentDictionary<string, string> IpAddressDictionary = new ConcurrentDictionary<string, string>();

        /// <summary>
        /// Invoked when an IdToken has been validated and produced an AuthenticationTicket.
        /// See weblink: https://docs.microsoft.com/en-us/dotnet/api/microsoft.aspnetcore.authentication.openidconnect.openidconnectevents.ontokenvalidated?view=aspnetcore-3.0
        /// </summary>
        /// <param name="context"></param>
        /// <returns></returns>
        public async Task OnTokenValidatedFunc(TokenValidatedContext context)
        {
            var token = context.SecurityToken;
            var userId = token.Claims.First(claim => claim.Type == "oid").Value;
            var givenName = token.Claims.First(claim => claim.Type == "given_name").Value;
            var familyName = token.Claims.First(claim => claim.Type == "family_name").Value;
            var username = token.Claims.First(claim => claim.Type == "preferred_username").Value;
            var ipAddress = token.Claims.First(claim => claim.Type == "ipaddr").Value;
            // Add the IP Address from the user's ID Token to the dictionary, we will remove
            // it from the dictionary when the user requests a sign out through OpenIDConnect. 
            IpAddressDictionary.TryAdd(userId, ipAddress);

            GeoHelper geoHelper = new GeoHelper();
            var geoInfo = await geoHelper.GetGeoInfo(ipAddress);

            string logEventCategory = "Open Id Connect";
            string logEventType = "User Sign In";
            string logEventSource = "MyProject";
            string logCountry = "";
            string logRegionName = "";
            string logCity = "";
            string logZip = "";
            string logLatitude = "";
            string logLongitude = "";
            string logIsp = "";
            string logMobile = "";
            string logUserId = userId;
            string logUserName = username;
            string logForename = givenName;
            string logSurname = familyName;
            string logData = "User with username [" + username + "] forename [" + givenName + "] surname [" + familyName + "] from IP Address [" + ipAddress + "] signed into the application [MyProject] Succesfully";

            if (geoInfo != null)
            {
                logCountry = geoInfo.Country;
                logRegionName = geoInfo.RegionName;
                logCity = geoInfo.City;
                logZip = geoInfo.Zip;
                logLatitude = geoInfo.Latitude.ToString();
                logLongitude = geoInfo.Longitude.ToString();
                logIsp = geoInfo.Isp;
                logMobile = geoInfo.Mobile.ToString();
            }

            // Tested on 31/08/2020
            Log.Information(
                "{@LogEventCategory}" +
                "{@LogEventType}" +
                "{@LogEventSource}" +
                "{@LogCountry}" +
                "{@LogRegion}" +
                "{@LogCity}" +
                "{@LogZip}" +
                "{@LogLatitude}" +
                "{@LogLongitude}" +
                "{@LogIsp}" +
                "{@LogMobile}" +
                "{@LogUserId}" +
                "{@LogUsername}" +
                "{@LogForename}" +
                "{@LogSurname}" +
                "{@LogData}",
                logEventCategory,
                logEventType,
                logEventSource,
                logCountry,
                logRegionName,
                logCity,
                logZip,
                logLatitude,
                logLongitude,
                logIsp,
                logMobile,
                logUserId,
                logUserName,
                logForename,
                logSurname,
                logData);

            await Task.CompletedTask.ConfigureAwait(false);
        }

        /// <summary>
        /// Invoked before redirecting to the identity provider to sign out.
        /// See weblink: https://docs.microsoft.com/en-us/dotnet/api/microsoft.aspnetcore.authentication.openidconnect.openidconnectevents.onredirecttoidentityproviderforsignout?view=aspnetcore-3.0&viewFallbackFrom=aspnetcore-3.1
        /// </summary>
        /// <param name="context"></param>
        /// <returns></returns>
        public async Task OnRedirectToIdentityProviderForSignOutFunc(RedirectContext context)
        {
            var user = context.HttpContext.User;
            var userId = user.FindFirst("http://schemas.microsoft.com/identity/claims/objectidentifier").Value;
            var givenName = user.Claims.FirstOrDefault(c => c.Type == ClaimTypes.GivenName)?.Value;
            var familyName = user.Claims.FirstOrDefault(c => c.Type == ClaimTypes.Surname)?.Value;
            var username = user.Identity.Name;

            string logEventCategory = "Open Id Connect";
            string logEventType = "User Sign Out";
            string logEventSource = "MyProject";
            string logCountry = "";
            string logRegionName = "";
            string logCity = "";
            string logZip = "";
            string logLatitude = "";
            string logLongitude = "";
            string logIsp = "";
            string logMobile = "";
            string logUserId = userId;
            string logUserName = username;
            string logForename = givenName;
            string logSurname = familyName;

            IpAddressDictionary.TryRemove(userId, out string ipAddress);

            if (ipAddress != null)
            {
                // Re-fetch the geo-location details which may be different than the login session
                // given the user might have been signed in using a cell phone and move locations.
                GeoHelper geoHelper = new GeoHelper();
                var geoInfo = await geoHelper.GetGeoInfo(ipAddress);

                if (geoInfo != null)
                {
                    logCountry = geoInfo.Country;
                    logRegionName = geoInfo.RegionName;
                    logCity = geoInfo.City;
                    logZip = geoInfo.Zip;
                    logLatitude = geoInfo.Latitude.ToString();
                    logLongitude = geoInfo.Longitude.ToString();
                    logIsp = geoInfo.Isp;
                    logMobile = geoInfo.Mobile.ToString();
                }
            }

            string logData = "User with username [" + username + "] forename [" + givenName + "] surname [" + familyName + "] from IP Address [" + ipAddress + "] signed out the application [MyProject] Succesfully";

            // Tested on 31/08/2020
            Log.Information(
                "{@LogEventCategory}" +
                "{@LogEventType}" +
                "{@LogEventSource}" +
                "{@LogCountry}" +
                "{@LogRegion}" +
                "{@LogCity}" +
                "{@LogZip}" +
                "{@LogLatitude}" +
                "{@LogLongitude}" +
                "{@LogIsp}" +
                "{@LogMobile}" +
                "{@LogUserId}" +
                "{@LogUsername}" +
                "{@LogForename}" +
                "{@LogSurname}" +
                "{@LogData}",
                logEventCategory,
                logEventType,
                logEventSource,
                logCountry,
                logRegionName,
                logCity,
                logZip,
                logLatitude,
                logLongitude,
                logIsp,
                logMobile,
                logUserId,
                logUserName,
                logForename,
                logSurname,
                logData);

            await Task.CompletedTask.ConfigureAwait(false);
        }

        /// <summary>
        /// Invoked before redirecting to the SignedOutRedirectUri at the end of a remote sign-out flow.
        /// See weblink: https://docs.microsoft.com/en-us/dotnet/api/microsoft.aspnetcore.authentication.openidconnect.openidconnectevents.onsignedoutcallbackredirect?view=aspnetcore-3.0
        /// Not currently in use becuase neither the user's ID Token or claims were present. We had to use the above method instead.
        /// </summary>
        /// <param name="context"></param>
        /// <returns></returns>
        public async Task OnSignedOutCallbackRedirectFunc(RemoteSignOutContext context)
        {

            await Task.CompletedTask.ConfigureAwait(false);
        }
    }
}