2019年8月14日 星期三

[ASP.NET Core] Identity Server 4 – Custom Event Sink


 ASP.NET Core   Identity Server 4   Event Sink  


Introduction



Events represent higher level information about certain operations in IdentityServer. Events are structured data and include event IDs, success/failure information, categories and details. (From docs.identityserver.io)


We are going to create a custom Event Sink which can save successful sign-in information into memory cache.
Notice that the event is fired on Auth Server application.





docker-openldap 1.2.4 (OpenLDAP 2.4.47)
ASP.NET Core 2.2.203
IdentityServer4 2.4.0
Nordes/IdentityServer4.LdapExtension 2.1.8



Implement


The source code is on my Github.

Enable raising events

Events are not turned on by default, make sure enabling raising events are configured in the ConfigureServices method.

Startup.cs : ConfigureServices

public void ConfigureServices(IServiceCollection services)
{
   services.AddMvc();

   #region Identity Server

   var builder = services.AddIdentityServer(options =>
   {
       options.Events.RaiseErrorEvents = true;
       options.Events.RaiseInformationEvents = true;
       options.Events.RaiseFailureEvents = true;
       options.Events.RaiseSuccessEvents = true;
   });
   #endregion
}


Since we will use Memory Cache and Session later when implementing the Custom Event Sink, we have to
1. Add IMemoryCache into IServiceCollection for DI
2. Add SessionMiddleware to enable session state for the application



Add IMemoryCache into IServiceCollection for DI

Add implementation of Microsoft.Extensions.Caching.Memory.IMemoryCache into IServiceCollection as following,

Startup.cs : ConfigureServices

 public void ConfigureServices (IServiceCollection services)
 {
        // … Skip

        services.AddMemoryCache();      
 }



Enable Session

Startup.cs: Configure

 public void Configure (IApplicationBuilder app, IHostingEnvironment env)
 {
        // … Skip

        app.UseSession();

        app.UseMvc ();
 }


Raise event(s)

Identity Server 4 uses the DI service: IEventService, to raise event(s) as below.
Notice that we cannot pass parameter while raising events, we save the given Token in Session so that we can get it later in an Event Sink class.

LdapController.cs

Here is the sample for raising the UserLoginSuccessEvent.

 [Route("api/[controller]")]
 [ApiController]
 public class LdapController : ControllerBase
    {
        private readonly ILdapUserStore userStore = null;
        private readonly IEventService events = null;
        private readonly IdentityServerTools tools = null;

        public LdapController(
            ILdapUserStore userStore,
            IEventService events,
            IdentityServerTools tools)
        {
            this.userStore = userStore;
            this.events = events;
            this.tools = tools;
        }

        [HttpPost("SignIn")]
        public async Task<IActionResult> SignIn([FromBody]LdapUser model)
        {
            // validate username/password against Ldap
            var user = this.userStore.ValidateCredentials(model.Username, model.Password);

            if (user != default(IAppUser))
            {
                // Response with authentication cookie
                await this.HttpContext.SignInAsync(user.SubjectId, user.Username);

                // Get the Access token
                var accessToken = await this.tools.IssueJwtAsync(lifetime: 3600, claims: new Claim[] { new Claim(JwtClaimTypes.Audience, model.ApiResource) });

                // Save Access token to current session
                this.HttpContext.Session.SetString("AccessToken", accessToken);

                // Write the Access token to response
                await this.HttpContext.Response.WriteAsync(accessToken);

                // Raise UserLoginSuccessEvent
                await this.events.RaiseAsync(new UserLoginSuccessEvent(user.Username, user.SubjectId, user.Username));

                return this.Ok();
            }
            else
            {
                return this.Unauthorized();
            }
        }
 }


Raising the event(s), such as UserLoginSuccessEvent, will trigger any Custom Event Sink that had already been added to IServiceCollection.
Next step we will create a Custom Event Sink.



Custom Event Sink

Create a new Event Sink class, "UserProfileCacheSink", which implement IdentityServer4.Services.IEventSink.


UserProfileCacheSink.cs

 public class UserProfileCacheSink : IEventSink
 {
        public async Task PersistAsync(Event evt)
        {
           
        }
    }


First inject the necessary services,

 public class UserProfileCacheSink : IEventSink
 {
        private IHttpContextAccessor httpContextAccessor = null;
        private readonly ICacheKeyFactory cacheKeyFactory = null;
        private readonly IMemoryCache cache = null;
        private readonly ILogger<UserProfileCacheSink> logger = null;
   
        public UserProfileCacheSink(IHttpContextAccessor httpContextAccessor, ICacheKeyFactory cacheKeyFactory, IMemoryCache cache, ILogger<UserProfileCacheSink> logger)
        {
            this.httpContextAccessor = httpContextAccessor;
            this.cacheKeyFactory = cacheKeyFactory;
            this.cache = cache;
            this.logger = logger;
        }
        
        public async Task PersistAsync(Event evt)
        {
           
        }
    }


Now we can implement the PersistAsync method, which will be fired when raising events.
The main logic of PersistAsync is to store the {Subject : JWT token} json string, for example { "jblin","xxxxxxxxxx" }, into Memory Cache.

 public async Task PersistAsync(Event evt)
 {
            if (evt.Id.Equals(EventIds.UserLoginSuccess))
            {
                if (evt.EventType == EventTypes.Success || evt.EventType == EventTypes.Information)
                {
                    var httpContext = this.httpContextAccessor.HttpContext;

                    try
                    {
                        if (this.httpContextAccessor.HttpContext.Session.IsAvailable)
                        {
                            var session = this.httpContextAccessor.HttpContext.Session;
                            var user = this.httpContextAccessor.HttpContext.User;
                            var subject = user.Claims.Where(x => x.Type == "sub").FirstOrDefault()?.Value;
                            var token = session.GetString("AccessToken");
                            string cacheKey = this.cacheKeyFactory.UserProfile(subject);
                            _ = await this.cache.GetOrCreateAsync<JObject>(cacheKey, async entry =>
                            {
                                entry.SlidingExpiration = TimeSpan.FromSeconds(600);
                                string jsonStr = $"{{\"{subject}\":\"{token}\"}}";
                                return JObject.Parse(jsonStr);
                            });

                            // Check if the cache exist
                            if (this.cache.TryGetValue<JObject>(cacheKey, out JObject tokenInfo))
                            {
                               Debug.WriteLine($"Cached: {tokenInfo.ToString()}");
                            }
                            
                        }
                    }
                    catch (Exception)
                    {
                    }
                }
                else
                {
                    this.logger.LogError($"{evt.Name} ({evt.Id}), Details: {evt.Message}");
                }
           }
 }


Last but very important!
We have to inject the Custom Event Sink, in other words, add the UserProfileCacheSink into IServiceCollection.
So that raising event(s) will also fires our Custom Event Sink.

Startup.cs:ConfigureServices                                                                              

 public void ConfigureServices (IServiceCollection services)
 {
         // … Skip

         #region Custom sinks
         services.AddScoped<IEventSink, UserProfileCacheSink>();
         #endregion
 }


Result: The information is stored in Memory Cache as following runtime log.




Source Code




Reference










沒有留言:

張貼留言