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

问题描述

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

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

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

  • OnTokenValidated
  • OnRedirectToIdentityProviderForSignOut
  • OnSignedOutCallbackRedirect

我创建的OpenIdEvents类只是为了清洁而从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;
 });
            

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

其中一些可选声明是在Azure的App清单文件中配置的,它们以第一种方法显示在安全令牌中,因此可以确定Azure设置正确。

从Azure应用清单文件提取

"optionalClaims": {
        "idToken": [
            {
                "name": "family_name","source": null,"essential": false,"additionalProperties": []
            },{
                "name": "given_name",{
                "name": "ipaddr","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.@R_639_4045@ion(
                "{@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);
        }

请参见下面的调试镜头:

enter image description here

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

enter image description here

我的问题:

用户注销时,从OpenIdConnectEvents触发的其他事件类型不能以相同的方式起作用,这就是我遇到的地方!

我尝试使用以下两种不同的事件类型进行测试:

  • OnRedirectToIdentityProviderForSignOut
  • OnSignedOutCallbackRedirect

用户注销过程中,每个触发的触发点都稍有不同,即,在用户实际单击按钮并注销之前,将用户重定向到Microsoft“注销”页面时会触发“ OnRedirectToIdentityProviderForSignOutFunc”

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

当我查看Debug信息时,我发现根本没有显示安全令牌,访问用户声明的唯一方法是通过导航到context.HttpContext.User.Claims

来读取上下文的另一部分。 >

调试屏幕截图:

enter image description here

方法如下所示:

        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'完全是浪费时间。看来,一旦用户点击“退出”按钮并返回到Web应用程序中的“退出页面,Microsoft便将其丢弃。

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

我想要做的只是简单地捕获用户的详细信息(声明)和他们正在连接的客户端会话的IP地址,并在他们登录和注销Web应用程序时记录下来。这真的有太多要问的吗!

有关此文档的信息非常稀疏,我非常感谢那些了解MS Identity Web和OpenIDConnect事件如何在后台运行的人提供的一些线索。

解决方案1 ​​=在“ OnRedirectToIdentityProviderForSignOut”期间能够从上下文访问IP地址声明,但是当前缺少该信息...

解决方案2(首选)=能够在“ OnSignedOutCallbackRedirect”期间访问用户声明,但目前没有列出所有声明。

预先感谢...

解决方法

正如我在上面的评论中提到的那样,并且还接受了Jean-Marc Prieur在上面的评论,即一旦用户完全退出,就不会再有任何主张,我最终只是抓住了用户上下文的详细信息通过OnRedirectToIdentityProviderForSignOutFunc方法,然后使用单独的GeoHelper类来获取大声唱歌时该人所在的目的地的IP地址(或者应该说要注销!)。

是的,这不是最理想的原因和影响,但是老实说,这不会给我带来太大的问题,而且在大多数情况下可能不会给其他人带来麻烦,在某人退出时登录并不会影响业务,更多了解系统使用情况。当有人到达MS弹出页面进行登出时,我们应该假设他们将继续进行并实际登出的案例有99%。

因此,以下是我用于实现上述情况的代码:

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();

// 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;
});

我的地理位置自定义类:

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,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类:

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}",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);
        }
    }
}
,

用户需要使用从OpenIdConnect生成的两个可能事件之一退出应用程序后,才能访问它们的声明

用户此时已退出。他/她不在了,所以没有任何意义,因为它已经注销,因此可以返回默认的空匿名用户。