Session Management in ASP.NET Core – Windows ASP.NET Core Hosting 2020 | Review and Comparison

In the era of REST, stateless distributed micro-services, open API, refresh tokens, etc. in enterprise (and not only) web applications “traditional” session approach still often been used. Generally, session idea is to link HTTP requests between each other and let web server to know about this connection. Using cookies for this purpose is quite convenient: web server while using session id, which is stored in the cookie and travels from client to server and from the server to client correlates which request belong to the same session.

During implementing web app in enterprise this year, the requirement of security department was to allow only one active session for one app user. If there is already user session in the app than the same user when logged on from another browser or a tab should override current existing session. Security department argued that in the case of a user has been hacked, it would be easier to track down the attacker for single browser.

To achieve this we’ve used .NET Core 3.1 SessionState. Few years ago I’ve implemented (or more precisely, adopted ) SqlSessionStateProvider to be used with WCF service, which was an abstraction layer for session storage in the database. This approach solved session issues in clustered Backend applications.

Implementation of .NET Core session is a totally new approach comparing to the “classic” .NET session and sometimes leads .NET developers be a little bit confused about it.

First of all, is session id generation: .NET Core server API returns encrypted sessionKey, which is an unique cache key (in .NET Core session stored in cache, and to make session working, cache storage has to be configurated). It can be done by adding caching middleware, for example for Redis, NCache, or Distributed Memory Cache(which isn’t an actual distributed cache. Cached items are stored by the app instance on the server where the app is running).

We’ve used Distributed SQL Server Cache, which is based on the SqlServer table (e.g. app.Cache) to store cached data:

Session objects are also stored in this table while .NET Core DistributedSession manages session: session sliding, data storage and session expiration.

The encrypted id column from the app.Cache table is stored in session cookie in .NET Core. It is not session id from the Session object.

The next important thing is encryption key for the session key. It is not a machineKey (as it was in .NET Framework). By default for IIS app it is stored in %LOCALAPPDATA%\ASP.NET\DataProtection-Keys folder. These settings are generally appropriate for apps running on a single machine. For our cluster we’ve used configured UNC share for cluster machines instead of at the %LOCALAPPDATA%:

var share = configuration.GetValue<string>(“KeysSharePath”);
if (!string.IsNullOrWhiteSpace(share))
{   services.AddDataProtection()
     .PersistKeysToFileSystem(new DirectoryInfo(share));
}

To track current user already has been logged in the application, app.UsersSessiontable has been created (here is FluentMigrator migration for the table):

Create.AppTable(TableNames.UsersSession)
  .WithColumn(“SessionKey”).AsString(449).NotNullable().PrimaryKey()
  .WithColumn(“SessionId”).AsString().NotNullable()
  .WithColumn(“Login”).AsString().NotNullable()
  .WithColumn(“UserId”).AsInt64().NotNullable().Indexed()
  .WithColumn(“TimeStamp”).AsDateTimeOffset(7).NotNullable()  .WithDefaultValue(DateTimeOffset.UtcNow);

In this way, when user logs in application we upsert record with SessionKeyUserIdUserLogin into app.UsersSessiontable:

public async Task UpdateUserSession()
        {
            using var connection = _connectionFactory.CreateConnection();

            string query = @"MERGE[app].[UsersSession] AS target
                             USING(SELECT @SessionId, @SessionKey, @Login, @UserId) AS source(SessionId, SessionKey, Login, UserId)
                             ON target.UserId = source.UserId
                             WHEN MATCHED THEN UPDATE SET SessionId = @SessionId, SessionKey = @SessionKey, Login = @Login, UserId = @UserId, TimeStamp = SYSDATETIMEOFFSET()
                             WHEN NOT MATCHED THEN
                             INSERT(SessionKey, SessionId, Login, UserId, TimeStamp)
                             VALUES(@SessionKey, @SessionId, @Login, @UserId, SYSDATETIMEOFFSET());";

            await connection.ExecuteAsync(query, new
            {
                _userContext.SessionId,
                _userContext.SessionKey,
                _userContext.CurrentUser.Login,
                UserId = _userContext.CurrentUser.Id
            }).ConfigureAwait(false);

            _logger.Info($"Update user session. SessionId: '{_userContext.SessionId}'. SessionKey: '{_userContext.SessionKey}'");
        }
public async Task StartSession(User user)
  {
      await _userContext.SetCurrentUser(user).ConfigureAwait(false);
      await _sessionRepository.UpdateUserSession().ConfigureAwait(false);
  }

where the SessionKey is unique key from app.Cache table and is encrypted in the session id cookie.

Important thing in process is to have this key during session creation on the server and upsert into the app.UsersSession table before session id has been sent to the browser.

In default .NET Core session implementation it is a private property: https://github.com/aspnet/Session/blob/master/src/Microsoft.AspNetCore.Session/DistributedSession.cs, but we have needed it to be public to have a possibility to save it in the database.

The solution was to override .NET Core session implementation to make session key public. In such a way, Distributed SQL Server Cache and overridden DistributedSession manage session expiration date, we do not care about it and only can check is current user has active session for session key received from the browser in the app.UsersSession table.

Due to upserts into app.UsersSession table we have only one record per user. Once more feature should be added is SessionMiddleware, where current user session is validated:

var isSessionStart = context.Request.Path.Value == "/api/authorization/logon";
            var isSessionStartAs = context.Request.Path.Value == "/api/authorization/logonas";
            var isSessionCheck = context.Request.Path.Value == "/api/session";

            context.Features.Set<ISessionFeature>(feature);

            if (!isSessionStart && !isSessionStartAs && !isSessionCheck)
            {
                var result = await _sessionService.ValidateSession(sessionKey).ConfigureAwait(false);

                if (result == false)
                {
                    context.Response.StatusCode = StatusCodes.Status403Forbidden;
                    await context.Response.WriteAsync("Session expired!");
                    return;
                }
            }

Happy coding!