ASP.NET
Core Identity Server 4 Event Sink
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
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
▋Add IMemoryCache into IServiceCollection for DI
▋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.