Session management in DotNetCore web applications

Shipping containers

Last month we talked about Cookie management in DotNetCore web applications and introduced a generic cookie service. Today we’re going to look at an alternative option for storing user data. Session state is a server store of information linked to a browsing session. Today let’s look at a technique for generic session management in dotnetcore web applications.

All code for this post can be located on my GitHub.

Before we start

If you’ve been in the field any length of time you’ve probably noticed that a lot of tutorials show you how to do something. I mean, that’s what they’re there for right? What they don’t typically show you is how to do it *correctly*. I’m going to do the same thing today. But wait! Please allow me quickly explain. We’ll be using in-memory session state for this exercise today.

That isn’t wrong, per se, but it isn’t right either. For an application of any size you will want to use a non-volatile session provider. Examples of that are Redis, a database, an XML file, or something else. Ok, Redis is actually in-memory as well right? Yeah yeah, let’s not get stuck on semantics. They run it as a separate service.

Getting Started

Let’s pretend you just loaded up your console and typed the following command: dotnet new mvc -n SessionManager

Now you have a brand spanking new website that, at time of writing, is using netcoreapp2.2. Wonderful.

Let’s open that up and head to our Startup.cs file and add the following lines to the ConfigureServices call. This will register ISession into the request lifecycle and allow us to access it from the httpcontext. We also expose the IHttpContextAccessor as an injectable since we need that later on.

// have to add this in order to access HttpContext from our own services
services.AddSingleton<IHttpContextAccessor, HttpContextAccessor>();
// register ISession into our HttpContext lifecycle
services.AddSession(options =>
{
	options.Cookie.HttpOnly = true;
});

ISession cannot be injected normally but you could do some magic to expose it as an injectable via the provider factory. For our purposes we don’t need to but let’s pretend you want to. You could do something like this: services.AddScoped(provider => provider.GetRequiredService().HttpContext?.Session);

We’re not quite done. We need to instruct the pipeline to actually use session. Pop into the Configure method and add app.UseSession(); somewhere before the app.UseMvc(...) call.

Adding a SessionService

Since we’re not going to be injecting ISession directly the question arises “what are we going to do?” Let’s create an ISessionService and set it up with some generic methods so we can serialize/deserialize whatever we want through it. If you looked at the post on cookie management this will look pretty similar.

public interface ISessionService
{
	T Get<T>(string key);
	void Set<T>(string key, T value);

	void Remove(params string[] keys);
}

public class SessionService : ISessionService
{
	private readonly ISession _session;

	public SessionService(IHttpContextAccessor httpContextRepository)
	{
		_session = httpContextRepository.HttpContext.Session;

		if (_session == null)
			throw new ArgumentNullException("Session cannot be null.");
	}

	public T Get<T>(string key)
	{
		throw new NotImplementedException();
	}

	public void Set<T>(string key, T value)
	{
		throw new NotImplementedException();
	}

	public void Remove(params string[] keys)
	{
		throw new NotImplementedException();
	}
}

In the code block above we’ve created the stub for our ISessionService and the skeleton for the implementation. We injected our IHttpContextAccessor and then accessed the Session property off of the HttpContext. We throw in a check to make sure Session isn’t null. This could happen if you try injecting this somewhere that doesn’t normally have an HttpContext (maybe a hosted service or something).

We need to jump back to Startup.cs and add services.AddScoped<ISessionService, SessionService>(); to our ConfigureServices call. Just add it directly after the AddSession call but order honestly doesn’t matter.

Implement the SessionService

Let’s make that service interesting. Go in and add the following code to the methods:

public T Get<T>(string key)
{
	string value = _session.GetString(key);
	if (string.IsNullOrWhiteSpace(value))
		return default(T);

	return Newtonsoft.Json.JsonConvert.DeserializeObject<T>(value);
}

public void Set<T>(string key, T value)
{
	if (value == null)
		Remove(key);
	else
		_session.SetString(key, Newtonsoft.Json.JsonConvert.SerializeObject(value));
}

public void Remove(params string[] keys)
{
	if (keys.IsNullOrEmpty())
		return;

	foreach (string key in keys)
		_session.Remove(key);
}

Pretty basic. In the Get<T> method we’re grabbing a string value out of session and, if it doesn’t exist, return the default for our type. Otherwise we deserialize from JSON into that type.

Our Set<T> method will remove the value if we pass a null or will serialize JSON to the session.

Our Remove method simply asks the session to remove a value. You don’t have to check it to exist first, session is friendly like that. It does reference an extension method I added IsNullOrEmpty that simply checks to see if an IEnumerable (in this case our array of string) has anything in it.

Putting it together

Open up HomeController.cs and modify it to look like this:

public class HomeController : Controller
{
	private const string c_CONTRIVEDSESSIONKEY = "contrived";
	private const string c_NAMESESSIONKEY = "basicname";

	private readonly ISessionService _sessionService;

	public HomeController(ISessionService sessionService)
	{
		_sessionService = sessionService;
	}

	public IActionResult Index()
	{
		var name = _sessionService.Get<string>(c_NAMESESSIONKEY);
		var contrived = _sessionService.Get<ContrivedValues>("contrived") ?? new ContrivedValues { Name = "Guest" };

		var viewModel = new HomeViewModel
		{
			Name = name,
			Contrived = contrived
		};

		return View(viewModel);
	}

	[HttpPost]
	public IActionResult PostBasic(NameRequest request)
	{
		_sessionService.Set(c_NAMESESSIONKEY, request.Name);

		return RedirectToAction(nameof(Index));
	}

	[HttpPost]
	public IActionResult PostContrived(ContrivedValues request)
	{
		_sessionService.Set(c_CONTRIVEDSESSIONKEY, request);

		return RedirectToAction(nameof(Index));
	}

	public IActionResult DeleteContrived()
	{
		_sessionService.Remove(c_CONTRIVEDSESSIONKEY);

		return RedirectToAction(nameof(Index));
	}

	public IActionResult Privacy()
	{
		return View();
	}

	[ResponseCache(Duration = 0, Location = ResponseCacheLocation.None, NoStore = true)]
	public IActionResult Error()
	{
		return View(new ErrorViewModel { RequestId = Activity.Current?.Id ?? HttpContext.TraceIdentifier });
	}
}

In the above code we’re injecting our new ISessionService into the HomeController and then setting up some pretty lame and contrived examples of how to interact with it.

The HomeController:Index method, for example, will attempt to grab a string value from session as well as an instance of ContrivedValues or default one if not found. We wrap those into a viewModel and pass into the view.

The HomeController:PostBasic action method takes in a NameRequest post value and sets it to the session. PostContrived takes a ContrivedValues post and sets it. DeleteContrived will remove the session object for our ContrivedValues.

ViewModels

Clearly the above will not compile yet. We need to add a some models. Go into the Models folder and add the following code to a new file HomeViewModel.cs.

public class HomeViewModel
{
	public string Name { get; set; }
	public ContrivedValues Contrived { get; set; }
}

public class NameRequest
{
	public string Name { get; set; }
}

public class ContrivedValues
{
	public int? Age { get; set; }
	public string Name { get; set; }
}

Index.cshtml (Views/Home)

Lastly we need to update our view to handle the demo.

@model SessionManagerDemo.Models.HomeViewModel
@{
	ViewData["Title"] = "Home Page";
}

<div class="row">
	<div class="col-md-3">
		<h2>Basic Cookie</h2>
		@if (!string.IsNullOrWhiteSpace(Model.Name))
		{
			<p>Your name is: @Model.Name</p>
		}
		<form action="@Url.Action("PostBasic")" method="post">
			<div class="form-group">
				<label for="name">Name: </label>
				<input type="text" class="form-control" name="name" placeholder="Name" />
			</div>
			<button type="submit" class="btn btn-primary">Submit</button>
		</form>
	</div>
	<div class="col-md-3">
		<h2>Cookie w/Default</h2>
		<p>Current values:</p>
		<ul>
			<li>Age: @Model.Contrived.Age</li>
			<li>Name: @Model.Contrived.Name</li>
		</ul>
		<form action="@Url.Action("PostContrived")" method="post">
			<div class="form-group">
				<label for="name">Name: </label>
				<input type="text" class="form-control" name="name" placeholder="Name" />
			</div>
			<div class="form-group">
				<label for="name">Age: </label>
				<input type="number" class="form-control" name="age" placeholder="Age" />
			</div>
			<button type="submit" class="btn btn-primary">Submit</button>
			<!-- yeah yeah, I know... this is naughty -->
			<a href="@Url.Action("DeleteContrived")" class="btn btn-danger">Delete</a>
		</form>
	</div>
</div>

Unit Tests

I’m not going to paste the unit tests here but the GitHub code has a few unit tests that also prove the functionality. Substituting the ISession is a bit of fun in this case because we’re using the ISession.GetString which is actually an extension method that wraps the ISession.TryGetValue call. Feel free to look and see how I accomplished mocking that one.

Conclusion

Session management in DotNetCore web applications is pretty simple. We only need to register the Session middleware and the session services but by itself it is pretty limited. Adding a generic based wrapper gives us a little more control and flexibility over the process. For anything beyond basic you’ll want to use a session state server of some kind rather than in-memory like this example shows.

All code from today’s post can be located on my GitHub.

Credits

Photo by frank mckenna on Unsplash