Need Quality Code? Get Silver Backed

.Net Core Login Persistence

6thJul

0

by Gary H

Home is where heart is. Heart where cookie is. Math clear: home is cookie.

Cookie Monster

Cookie based authentication uses encrypted cookies in the user's browser to authenticate them. This post looks at how we can extend authentication to persist the encryption keys in a database so that we can authenticate a cookie on multiple machines in a server farm or to persist them across application resets.

How Cookie Authentication Works

Using cookie authentication in .Net Core is simple. First, you add the authentication middleware:

services
	.AddAuthentication(
		CookieAuthenticationDefaults.AuthenticationScheme)
	.AddCookie(
		CookieAuthenticationDefaults.AuthenticationScheme);	

In your controller where you handle a users login you will verify whatever you want to verify - usually a username and password - and if the user is valid you create an Identity, add some claims and finally "Sign In" the user:

var identity = new ClaimsIdentity(
	CookieAuthenticationDefaults.AuthenticationScheme,
	ClaimTypes.Email, 
	ClaimTypes.Role); // Roles are stored in Role claims

identity.AddClaim(new Claim(ClaimTypes.Email, user.Email));
foreach (var role in whereverYouAreLoadingRolesFrom)
{
	identity.AddClaim(
		new Claim(ClaimTypes.Role, role.ToString()));
}

var principal = new ClaimsPrincipal(identity);
await HttpContext.SignInAsync(
	CookieAuthenticationDefaults.AuthenticationScheme, 
	principal, 
	new AuthenticationProperties { IsPersistent = true });

Under the covers, calling SignInAsync will issue an authentication ticket. The ticket is a simple class containing the information for the Identity that you have created. A version of this ticket is encrypted and transmitted back to the caller as a Cookie. On future visits to the site the middleware will first check for the existence of an authentication cookie, if one is found it will be decrypted into a ticket, verified and if it still looks good the user is logged in for the context of the request. This happens without needing to hit your login controller again - the details of the Identity are all in the cookie.

The Problem

The issue comes when you run multiple servers in a server farm or are constantly restarting your application during development. The encryption keys that are used to create the authentication cookie are ephemereal, they will be regenerated every time your process restarts. This also means that the keys will be different on every machine running your site. When cookie authentication sees a token it can't decrypt it assumes the user is not authorised and falls back to the default login flow.

Microsoft Recommends using a shared path with keys on the file system, encrypted at rest with a certificate. This is unwieldy, difficult to deploy and introduces excitement around key distribution and certificate management. They also offer packages to store your key in Azure or you can pull in Entity Framework and write some code to store your keys in a Database.

We aren't using Azure or Entity Framework so the thought of using either of these just for key storage is pretty unpleasant. Our solution was to implement a custom, lightweight repository for the data protection keys that uses Dapper to store the key XML in a Postgres database. This gives us the benefits of persistence and shared keys across a server farm whilst using the bare minimum tools we are already using. Whilst we use Postgres and Dapper, you can adapt the method used here to any combination of DB provider and ORM/Query provider.

Key Management Repository

The first step is to look at how the Data Protection service actually protects data. Looking at AddKeyManagementOptions we can see that you can provide a custom instance to the XmlRepository property. This is the hook we will use to inject our own code. Looking at the interface we need to accept an XElement with a friendly name or return all XElements in the repository. Knowing this we can define our schema:

CREATE TABLE LoginKeys (
	ConfigName TEXT NOT NULL,
	ConfigDocument XML NOT NULL,

	CONSTRAINT "pk_loginkeys:configname" PRIMARY KEY (ConfigName)
);

With a schema defined we can build a simple repository:

public class LoginKeysRepository : IXmlRepository
{
	private readonly string _readOnlyConnectionString;
	private readonly string _readWriteConnectionString;

	public LoginKeysRepository(
		string readOnlyConnStr,
		string readWriteConnStr)
	{
		_readOnlyConnectionString 
			= readOnlyConnectionString;
		_readWriteConnectionString 
			= readWriteConnectionString;
	}

	public IReadOnlyCollection<XElement> GetAllElements()
	{
		using var conn = CreateConnectionAsync().Result;
		return conn.Query<XElement>(
			"SELECT ConfigDocument FROM LoginKeys").ToList();
	}

	public void StoreElement(XElement element, string friendlyName)
	{
		var sql = @"
			INSERT INTO LoginKeys(ConfigName, ConfigDocument) 
			VALUES (@friendlyName, @xml)
			ON CONFLICT ON CONSTRAINT ""pk_loginkeys:configname"" 
			DO
				UPDATE 
					SET ConfigDocument = EXCLUDED.ConfigDocument;";

		using var conn = 
			CreateConnectionAsync(requireWrite: true).Result;
		conn.Execute(sql, 
			new { AppId, friendlyName, xml = element });
	}

	protected async Task<IDbConnection> CreateConnectionAsync(
		bool requireWrite = false, 
		CancellationToken cancellationToken = default)
	{
		var conn = new NpgsqlConnection(requireWrite 
					? _readWriteConnectionString 
					: _readOnlyConnectionString);

		await conn.OpenAsync(cancellationToken);
		return conn;
	}
}

The last step is to plug our repository into the Data Protection service in our startup class:

services
	.AddDataProtection()
	.SetApplicationName($"YourApplicationName}")
	.AddKeyManagementOptions(kmo =>
	{
		kmo.XmlRepository = new LoginKeysRepository(
			"ReadOnlyConnectionString",
			"ReadWriteConnectionString"
		);
	});

And we are done! All of the machines in our server farm are able to query the database for any and all keys for authentication and our local development apps can keep you logged in after a process restart. We have extended the above with an AppId which allows us to store the keys for multiple applications efficiently in one database.

Find this post useful? Follow us on Twitter

C# , Security , ASP.Net , .NetCore

Comments are Locked for this Post