ASP.NET Core 2.0 makes it very easy and straightforward to setup a cookie authentication mechanism in your application. Framework provides numerous ways to achieve that, with or without ASP.NET Core Identity membership system.
This post is part of a series on ASP.NET Core 2.0 Authentication and I am about to talk about cookie events and how to respond to them.
Application
In this demo, I am going to talk about cookie events in ASP.NET Core 2.0. The application in question is based on previous post of this series, ASP.NET Core 2.0 Authentication with local logins - Implementing claims transformation, so if you haven't followed along, it would be better to check this out first before diving into code that's coming next.
Code for this demo can be found here.
Very little has changed, the most significant change (aside from this post focus which is cookie events) is the database schema and data, so you might want to update accordingly by running the setup.cmd batch script that is found within the application source code.
You might notice there is a new table, the [dbo].[Log]
, which will be used for logging purposes.
Handling back-end volatility
In the previous post, I talked about enhancing an authenticated Identity with additional claims, utilizing a user profile store and the IClaimsTransformation
interface.
This service responds to changes that happen to these additional claims, as it is executed for each request, as long as the user is authenticated.
Required claims usually are set during the cookie creation, that is when the user logs in, and most of the times, these are identity values that you define as the most important for your application to function as expected. Without them you might end up with all kinds of trouble, minor or major, doesn't matter as long as it is trouble.
How your application should react when these identity values have been modified in the back-end? Or even worse, how your application should react when the user in question is deleted from the back-end store?
The latter spells a serious security hole, as the user's cookie still remains in the browser session until it expires after a specified amount time. That means this user will continue to have access to the application, even though technically he does not exist anymore!
That said, we need to find a way to respond to these back-end changes, by either updating claims whose value has changed or reject the principal (destroy the cookie) if user does not exist anymore.
Thankfully, ASP.NET Core provides a way to tackle this by the CookieAuthenticationEvents
class, which is part of the Microsoft.AspNetCore.Authentication.Cookies
namespace. Let's see how it works.
Implementation
To start listening to events you should setup cookie authentication in Startup.cs.
There you can define either some inline delegate handlers or a class which derives from CookieAuthenticationEvents
.
I will focus on the OOP approach rather delegates in this post, though it is really easy to setup an inline delegate, you just need to use the Events
property on the CookieAuthenticationOptions
class, which is passed as an input in the AddCookie
method overload.
Events
property, which is a type of CookieAuthenticationEvents
, supports more event handlers which are the following:
OnRedirectToAccessDenied
: Event is invoked when application is redirecting to access denied screen.OnRedirectToLogin
: Event is invoked when a redirect to login page occurs.OnSigningOut
: This event is invoked before the user signs out.OnSignedIn
: This event is invoked when the user has signed in.OnSigningIn
: This event is invoked before the user signs in.OnValidatePrincipal
: This event is invoked on each request for an authenticated user and can be used to overwrite the existing cookie identity.OnRedirectToLogout
: This event is invoked when a redirect to logout page occurs.OnRedirectToReturnUrl
: This event is invoked when a redirect to the return URL that is specified occurs.
And the official documentation's definition for this property is:
The handler calls methods on the provider that give the app control on certain processing points. If events aren't provided, a default instance is supplied that does nothing when the methods are called.
For more info, check out ASP.NET Security repository implementation of CookieAuthenticationEvents.
OOP approach
An OOP approach is recommended when you wish to inject and work with other dependencies, for instance certain service implementations.
To register a class that handles the cookie events mentioned earlier, you need to assign it to the EventsType
property of CookieAuthenticationOptions
class in cookie setup. This property as it name states, takes a type, which must be a type of CookieAuthenticationEvents
. Let's see its definition by the official documentation:
Used as the service type to get the Events instance instead of the property
That said, let's take a look at the following code snippet.
I have created a custom CookieEvents
class, which inherits from CookieAuthenticationEvents
. First, I register this class on the container, in this scenario I added this as scoped. Registering this class as scoped makes sense, as this is going to be invoked for each request.
After some standard cookie authentication setup, I assign the result of typeof(CookieEvents)
to the EventsType
property. Presto, we are done with the configuration, let's implement this class.
CookieEvents
This class inherits from CookieAuthenticationEvents
, which contains virtual method handlers for all the events mentioned earlier. I have defined as dependencies the IUserRepository
, which contains methods to fetch a user by his username and the ILogger
service to log messages to the database and more specifically to the [dbo].[Log]
table.
Following snippet shows the class declaration and the constructor implementation. I will explore this class method by method until I reach to the ValidatePrincipal
method, which is the main point of interest.
More info on how to setup a custom database logger in ASP.NET coming later. For now I will focus on the virtual methods I've overridden and these are SignedIn
, SigningIn
and ValidatePrincipal
methods.
SignedIn
In this method I just log an information message to the database on the user that has signed in. This handler is invoked when a user has successfully signed in.
SigningIn
This method is called when a user attempts to sign in. Looking at its context passed, the CookieSigningInContext
, it doesn't let you do much, as it is invoked early in the request lifecycle, at best its a good place to modify some cookie options before signing the user in. That's what I am doing in the following code, I change the default SameSiteMode
from Lax
to Strict
. More info on SameSiteMode
:
Indicates whether the browser should allow the cookie to be attached to same-site requests only (
SameSiteMode.Strict
) or cross site requests using safe HTTP methods and same-site requests (SameSiteMode.Lax
). When you set theSameSiteMode.None
the cookie header value isn't set. The cookie policy middleware might overwrite the value that your provide. To support OAuth authentication, the default value isSameSite.Lax
.
My application doesn't include any cross site requests, or OAuth, so it safe to change the SameSiteMode
to Strict
.
Next up, is the ValidatePrincipal
method (in ASP.NET Core 1.x it was ValidateAsync
, in 2.0+ it is ValidatePrincipal
). As mentioned earlier, this method intercepts each authenticated request, and judging by its context, it does let you access the current HttpContext
fully, as well as the current authenticated ClaimsPrincipal
. Through this method I am able to modify the user's claims and his/her principal or even reject the principal and essentially throw him/her out. Take a look at the implementation below and I will come back and discuss its bits and pieces.
Pretty much standard implementation, at first I check if the user is indeed authenticated and the Name claim is populated, because in order to fetch his additional claims, I need the unique username stored in the Name claim.
Now, the entire process of updating the ClaimsPrincipal
if there are any back-end changes is wrapped within a try..catch
block, whereas in the case of an exception, I log the exception thrown, into the database and then reject the principal entirely, which logs the user out. Of course, you might not want that in your case, you might still want the user logged in, though for demo purposes, I'd like to log him out in case of error.
So, I am using the IUserRepository
to find a user by a username and in case I don't find him, it means that he has been deleted from the database, so I immediately reject the principal, again, signing him out. But in the case I find the user, then I run a series of tests to identify the change, update the the claims and user's cookie.
I have a convention in the [dbo].[User]
table, when an update occurs, the UpdatedOn
field is updated with the current date and time. I've added a custom claim in the cookie with the arbitrary ClaimType
of "UpdatedOn"
which is a DateTime
value. Check out the LoginAsync
method in AuthController
, it is changed a bit to include the UpdatedOn claim.
So, if an update occurs for a particular user, the UpdatedOn
field will be invalidated with a new DateTime
value, which is exactly what I am testing here, I am testing the claim value stored in the cookie against the database value. If the value fetched from the database is a future DateTime
, then it means something was changed, so better proceed into updating the cookie's identity values and user's current principal (his cookie).
By updating the user's claims, I then proceed into creating a new ClaimsIdentity
instance, of course based on the existing user claims and authentication type for that identity and then onto creating a new ClaimsPrincipal
. In order to update the current principal, use the ReplacePrincipal
method of the context and pass your new ClaimsPrincipal
there. Be sure to set the ShouldRenew
property of the context to true if you want to update the principal. Notice that we do not need to return anything, all the heavy lifting is done by the context passed.
The snippet below shows the private helper methods that are used by the ValidatePrincipal
handler.
UserIsNotAuthenticatedOrNameClaimIsEmpty
method is self explanatory, so let's move to the others.
The UpdateUserClaimsIfModified
is just running through all the cookie claims and if it detects a change it updates that claim, using the UpdateClaimIfChanged
method.
Also, something that you should know, is that you cannot access additional claims from this method. Let's say you have a IClaimsTransformation
implementation in place and you want to access the claims generated by the profile store. Well, you cannot, as these are stored in the cookie per request and you won't be able to access them from the ValidatePrincipal
method, as this runs first in the request lifecycle.
Bonus - ASP.NET Core 2.0 database logging
This is a bonus section, I will briefly explain about ASP.NET Core logging and how to make a database logging provider.
First, take a glance on the log table, which I store the log events.
And this is the EventLog
model that I construct for logging an event in the database.
And now follows a snippet from the Startup.cs
configuration class.
In order to implement logging in ASP.NET Core, you need an ILoggerFactory
, which is an object that supports various kinds of logs and can be injected/configured in the Configure
method. Having a logging factory, I can add various providers to it, build-in or custom. Then as your code requests for a certain logger, the logger factory will use the logger provider to create a new logger.
In the code below, you can see the high overview implementation, I register a custom ILoggerProvider
, which is the DatabaseLoggerProvider
to the ILoggerFactory
, passing some dependencies, which are
- The database commander, which communicates with the underlying database.
- A filter function, which declares the permitted categories for the Logger to log. In this case, the category string will be the class from which the logger was called.
So, that said our first move would be to create LoggerProvider, which is a class that implements the ILoggerProvider
interface, with CreateLogger
and IDisposable.Dispose
methods. In CreateLogger
we return an instance of a logger class that implements the ILogger
interface.
As you see, the provider is fairly simple, I just instantiate a new DatabaseLogger
in the CreateLogger
method, passing the categoryName
and the filter that I declared earlier in the Configure
method.
The ILogger interface contains three methods to implement
IDisposable BeginScope<TState>(TState state)
. This method provides the logger the ability to generate a disposable scope, essentially adding some context to the log events of that logger. Check out this post for more info on BeginScope semantics.bool IsEnabled(LogLevel level)
. This method takes a LogLevel and tells the caller whether that LogLevel satisfies this specific logger. Of course, you can have a more fine-grained control on under which circumstances you enable this logger.void Log<TState>(...)
. This method has a fairly lengthy signature, so I omitted it. This is where the logging actually happens.
In order for the logger to log to the database, it first needs to know if it's allowed to do so, by calling the IsEnabled
method, where I filter the category based on the categories that I allow to log. Remember, category is going to be the full class name in which a call to the logger was issued.
Then, I construct the EventLog
object and log it into the database, calling the Execute
method of the Syrx commander.
Action!
Let's see all these in action. I run the application, which opens a new browser window.
Let's try to login. This will create a new log entry in the [dbo].[Log]
table.
And indeed, after I hit Submit, it logs the sign-in event.
Let's try and modify logged-in user's email, to john.doe@email.com
. First, I will visit the /profile
page to verify the user's claims. Email address is the last claim in the list.
I will update the UpdatedOn
field as well to the current date.
If I reload the page, I will see the email address updated.
I will try to remove that user now. I expect me to automatically sign-out after a refresh. First, I will delete the user from the database.
And now I will try to hit F5
and I should redirect to the login page (/profile
is protected, that's why I am send back to login. If I try to navigate somewhere else, for instance /home
, I will just see that I am no longer signed-in).
Summary
In this post we saw how it is possible to respond to back-end changes by either updating the values that have changed or by signing-out the user in question, essentially destroying the associated cookie, thus avoiding security holes, like users that shouldn't exist, still lurking around, all this thanks to CookieAuthenticationEvents
class.
Github repository is here.
In coming posts, I will continue with local logins, talking about authorization policies and then move to social logins with various providers.
This post is part of the ASP.NET Core 2.0 Authentication series.
- ASP.NET Core 2.0 Cookie Authentication - Local logins
- ASP.NET Core 2.0 Authentication with local logins - Implementing claims transformation
- ASP.NET Core 2.0 Authentication with local logins - Responding to backend changes
- ASP.NET Core 2.0 Authentication with local logins - Implementing custom authorization policies
- ASP.NET Core 2.1 Authentication with social logins
- ASP.NET Core 2.0 Authentication with social logins - Implementing a profile store
- ASP.NET Core 2.0 Authentication with Azure Active Directory
- ASP.NET Core 2.0 Authentication with Azure Active Directory B2C
- ASP.NET Core 2.0 Authentication, IdentityServer4 and Angular
Comments powered by Disqus.