Code listings for blog post at https://stefanolsen.com/posts/how-to-replace-session-state-in-a-scalable-environment/
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
public class AccountController : Controller | |
{ | |
public IActionResult Login() | |
{ | |
return View(); | |
} | |
[HttpPost] | |
[ValidateAntiForgeryToken] | |
public async Task<IActionResult> Login(LoginViewModel model) | |
{ | |
if (!ModelState.IsValid) | |
{ | |
return View(model); | |
} | |
// Mocked: call a CRM system to verify that the user can be logged in. | |
var crmService = new DummyCrmService(); | |
bool loggedIn = crmService.Validate(model.UserName, model.Password); | |
if (loggedIn) | |
{ | |
// Mocked: load user data from a CRM system. | |
var basicData = crmService.GetBasicData(model.UserName); | |
var supportInquiries = crmService.GetSupportInquiries(model.UserName); | |
// Generate a random user token for this user session. | |
Guid userToken = Guid.NewGuid(); | |
var customerDataService = new CustomerDataService(); | |
// Store the user token - user id relation in Redis. | |
await customerDataService.StoreUserToken(userToken, basicData.UserId); | |
// Store the user data in Redis. | |
await customerDataService.StoreBasicDataAsync(userToken, basicData); | |
await customerDataService.StoreSupportInquiriesDataAsync(userToken, supportInquiries); | |
// Store the user token as an identity claim. | |
var claims = new List<Claim> | |
{ | |
new Claim("UserToken", userToken.ToString("N"), ClaimValueTypes.String, "http://testsite.local") | |
}; | |
var userIdentity = new ClaimsIdentity(claims); | |
var userPrincipal = new ClaimsPrincipal(userIdentity); | |
// Sign in the user. | |
await HttpContext.Authentication.SignInAsync("Cookies", userPrincipal); | |
return RedirectToAction("Index", "MyProfile"); | |
} | |
ModelState.AddModelError("", "Invalid login!"); | |
return View(model); | |
} | |
public async Task<IActionResult> LogOff() | |
{ | |
// Try to clean up data in Redis, if possible. | |
var claimsPrincipal = HttpContext.User; | |
var userTokenString = claimsPrincipal.FindFirst("UserToken")?.Value; | |
if (!string.IsNullOrWhiteSpace(userTokenString)) | |
{ | |
var userToken = Guid.Parse(userTokenString); | |
var customerDataService = new CustomerDataService(); | |
await customerDataService.DeleteUserDataAsync(userToken); | |
} | |
// Sign out the user. | |
await HttpContext.Authentication.SignOutAsync("Cookies"); | |
return RedirectToAction("Login", "Account"); | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
public class CustomerDataService | |
{ | |
private static readonly TimeSpan DefaultTokenExpiryMinutes = TimeSpan.FromMinutes(5); | |
private static readonly TimeSpan DefaultDataExpiryMinutes = TimeSpan.FromMinutes(120); | |
private const string UserTokenKeyFormat = "tokens:{0}"; | |
private const string BasicDataKeyFormat = "basicdata:{0}"; | |
private const string SupportInquiriesKeyFormat = "supportinquiries:{0}"; | |
private const string SupportInquiryKeyFormat = "supportinquiry:{0}"; | |
/// <summary> | |
/// Validates that the user token exists and refreshes the user token TTL. | |
/// </summary> | |
public async Task<bool> ValidateUserTokenExistsAsync(Guid userToken) | |
{ | |
using (var connection = await GetConnection()) | |
{ | |
var database = connection.GetDatabase(); | |
// Try to look up the user id by the user token. | |
long? userId = await GetUserId(database, userToken, refreshTtl: true); | |
// If the user id exists, we can consider the session to be active. | |
bool exists = userId.HasValue; | |
return exists; | |
} | |
} | |
/// <summary> | |
/// Deletes all user entries. | |
/// </summary> | |
public async Task DeleteUserDataAsync(Guid userToken) | |
{ | |
using (var connection = await GetConnection()) | |
{ | |
var database = connection.GetDatabase(); | |
// Try to look up the user id by the user token. | |
long? userId = await GetUserId(database, userToken); | |
if (!userId.HasValue) | |
{ | |
return; | |
} | |
// Generate a user token key string from the GUID. | |
string tokenString = userToken.ToString("N"); | |
// Generate a specific key for the user's token. | |
string tokenKey = string.Format(UserTokenKeyFormat, tokenString); | |
// Generate a specific key for the user's profile hash. | |
string basicDataKey = string.Format(BasicDataKeyFormat, userId); | |
// Delete the entries from Redis. | |
await database.KeyDeleteAsync(tokenKey, CommandFlags.DemandMaster | CommandFlags.FireAndForget); | |
await database.KeyDeleteAsync(basicDataKey, CommandFlags.FireAndForget); | |
} | |
} | |
/// <summary> | |
/// Tries to get the basic data of a specific user. | |
/// </summary> | |
public async Task<BasicData> GetBasicDataAsync(Guid userToken) | |
{ | |
using (var connection = await GetConnection()) | |
{ | |
var database = connection.GetDatabase(); | |
// Try to look up the user id by the user token. | |
long? userId = await GetUserId(database, userToken); | |
if (!userId.HasValue) | |
{ | |
return null; | |
} | |
// Generate a specific key for the user's profile hash. | |
string basicDataKey = string.Format(BasicDataKeyFormat, userId); | |
// Get the profile hash entries for the user token. | |
var hashes = await database.HashGetAllAsync(basicDataKey); | |
// Reset the key TTL (expiration), for sliding expiration like in regular session state. | |
await database.KeyExpireAsync(basicDataKey, DefaultDataExpiryMinutes); | |
// Convert the Redis hash entries into properties on a BasicData instance. | |
var data = hashes.ConvertFromRedis<BasicData>(); | |
return data; | |
} | |
} | |
/// <summary> | |
/// Tries to get a collection of support inquiries related of a specific user. | |
/// </summary> | |
public async Task<ICollection<SupportInquiry>> GetSupportInquiriesAsync(Guid userToken, int offset, int count) | |
{ | |
using (var connection = await GetConnection()) | |
{ | |
var database = connection.GetDatabase(); | |
// Try to look up the user id by the user token. | |
long? userId = await GetUserId(database, userToken); | |
if (!userId.HasValue) | |
{ | |
return null; | |
} | |
// Generate a specific key for the user's inquiry list. | |
string inquiriesListKey = string.Format(SupportInquiriesKeyFormat, userId); | |
// Reset the key TTL (expiration), for sliding expiration like in regular session state. | |
await database.KeyExpireAsync(inquiriesListKey, DefaultDataExpiryMinutes); | |
// Get the id's of all inquiries for the user token. | |
RedisValue[] redisValues = database.ListRange(inquiriesListKey, offset, offset + count); | |
if (redisValues == null) | |
{ | |
return null; | |
} | |
var inquiries = new List<SupportInquiry>(); | |
foreach (var redisValue in redisValues) | |
{ | |
long inquiryId = (long)redisValue; | |
// Generate a specific key for the inquiry hash. | |
string inquiryDataKey = string.Format(SupportInquiryKeyFormat, inquiryId); | |
// Get the inquiry hash entries for the inquiry id. | |
var hashes = await database.HashGetAllAsync(inquiryDataKey); | |
// Convert the Redis hash entries into properties on a SupportInquiry instance. | |
var inquiry = hashes.ConvertFromRedis<SupportInquiry>(); | |
inquiries.Add(inquiry); | |
} | |
return inquiries; | |
} | |
} | |
/// <summary> | |
/// Stores the user token - user id relation. | |
/// </summary> | |
public async Task StoreUserToken(Guid userToken, long userId) | |
{ | |
using (var connection = await GetConnection()) | |
{ | |
var database = connection.GetDatabase(); | |
// Generate a user token key from the GUID. | |
string tokenString = userToken.ToString("N"); | |
// Generate a specific key for the user's profile hash. | |
string tokenKey = string.Format(UserTokenKeyFormat, tokenString); | |
// Store the user id under the user token key, and set a TTL on the key. | |
await database.StringSetAsync(tokenKey, userId, DefaultTokenExpiryMinutes, When.Always, CommandFlags.DemandMaster); | |
} | |
} | |
/// <summary> | |
/// Tries to store an instance of BasicData for a user, identified by a user token. | |
/// </summary> | |
public async Task StoreBasicDataAsync(Guid userToken, BasicData data) | |
{ | |
using (var connection = await GetConnection()) | |
{ | |
var database = connection.GetDatabase(); | |
// Try to look up the user id by the user token. | |
long? userId = await GetUserId(database, userToken); | |
if (!userId.HasValue) | |
{ | |
return; | |
} | |
// Convert basic data properties to Redis hash entries. | |
var hashes = data.ToHashEntries(); | |
// Generate a specific key for the user's profile hash. | |
string basicDataKey = string.Format(BasicDataKeyFormat, userId); | |
// Store the hash entries and set a TTL (expiration) on the key. | |
await database.HashSetAsync(basicDataKey, hashes); | |
await database.KeyExpireAsync(basicDataKey, DefaultDataExpiryMinutes); | |
} | |
} | |
/// <summary> | |
/// Tries to store a collction of SupportInquiry instances, identified by a user token. | |
/// </summary> | |
public async Task StoreSupportInquiriesDataAsync(Guid userToken, ICollection<SupportInquiry> inquiries) | |
{ | |
using (var connection = await GetConnection()) | |
{ | |
var database = connection.GetDatabase(); | |
// Try to look up the user id by the user token. | |
long? userId = await GetUserId(database, userToken); | |
if (!userId.HasValue) | |
{ | |
return; | |
} | |
// Generate a specific key for the user's inquiry list. | |
string inquiriesListKey = string.Format(SupportInquiriesKeyFormat, userId); | |
// Remove a potential existing list key and its values (to avoid duplicates). | |
await database.KeyDeleteAsync(inquiriesListKey); | |
foreach (var inquiry in inquiries) | |
{ | |
// Convert inquiry properties to Redis hash entries. | |
var hashes = inquiry.ToHashEntries(); | |
// Generate a specific key for the inquiry hash. | |
string inquiryDataKey = string.Format(SupportInquiryKeyFormat, inquiry.Id); | |
// Store the hash entries and set a TTL (expiration) on the key. | |
await database.HashSetAsync(inquiryDataKey, hashes); | |
// Store the ID of this inquiry in a list of inquiry IDs. | |
await database.ListRightPushAsync(inquiriesListKey, inquiry.Id); | |
} | |
// Set a TTL (expiration) on the list key. | |
await database.KeyExpireAsync(inquiriesListKey, DefaultDataExpiryMinutes); | |
} | |
} | |
/// <summary> | |
/// Tries to look up a user id given a user token GUID. If specified, it also refreshes the current TTL. | |
/// </summary> | |
private static async Task<long?> GetUserId(IDatabaseAsync database, Guid userToken, bool refreshTtl = false) | |
{ | |
string tokenString = userToken.ToString("N"); | |
string tokenKey = string.Format(UserTokenKeyFormat, tokenString); | |
// Look up a user id string. | |
// This is a string per token, as these can be expired independently. | |
RedisValue idValue = await database.StringGetAsync(tokenKey); | |
bool exists = !idValue.IsNullOrEmpty; | |
if (!exists) | |
{ | |
return null; | |
} | |
if (refreshTtl) | |
{ | |
// If the user exists and the caller requests so, reset the key TTL (expiration). | |
// This make sliding expiration work like in regular session state. | |
await database.KeyExpireAsync(tokenKey, DefaultTokenExpiryMinutes, CommandFlags.FireAndForget); | |
} | |
return (long)idValue; | |
} | |
private static async Task<ConnectionMultiplexer> GetConnection() | |
{ | |
return await ConnectionMultiplexer.ConnectAsync("localhost"); | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
public static class CustomPrincipalValidator | |
{ | |
public static async Task ValidateAsync(CookieValidatePrincipalContext context) | |
{ | |
var customerDataService = new CustomerDataService(); | |
var userPrincipal = context.Principal; | |
var userTokenString = userPrincipal.FindFirst("UserToken")?.Value; | |
if (string.IsNullOrWhiteSpace(userTokenString)) | |
{ | |
// If the principal does not contain a user token, sign out the user. | |
context.RejectPrincipal(); | |
await context.HttpContext.Authentication.SignOutAsync("Cookies"); | |
return; | |
} | |
// Validate that the user token (still) exists in Redis. | |
var userToken = Guid.Parse(userTokenString); | |
bool profileExists = await customerDataService.ValidateUserTokenExistsAsync(userToken); | |
if (!profileExists) | |
{ | |
// If the principal does not exist or is expired in Redis, sign out the user. | |
context.RejectPrincipal(); | |
await context.HttpContext.Authentication.SignOutAsync("Cookies"); | |
} | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
public class BasicData | |
{ | |
public long UserId { get; set; } | |
public string FirstName { get; set; } | |
public string LastName { get; set; } | |
public string Email { get; set; } | |
public string PhoneNumber { get; set; } | |
public string AddressLine1 { get; set; } | |
public string PostalCode { get; set; } | |
public string City { get; set; } | |
public string Country { get; set; } | |
public bool HasMarketingPermission { get; set; } | |
public DateTime CreatedDate { get; set; } | |
} | |
public class SupportInquiry | |
{ | |
public long Id { get; set; } | |
public bool IsClosed { get; set; } | |
public string Description { get; set; } | |
public DateTime SubmittedDate { get; set; } | |
public string SubmitterEmail { get; set; } | |
public string SubmitterName { get; set; } | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
[Authorize(Policy = "LoggedInUser")] | |
public class MyProfileController : Controller | |
{ | |
private readonly CustomerDataService _customerDataService = new CustomerDataService(); | |
public async Task<IActionResult> Index() | |
{ | |
var claimsPrincipal = HttpContext.User; | |
// Find and parse the user token GUID from claim. | |
// TODO: This could be abstracted away, for instance to a base controller or a helper class. | |
var userTokenString = claimsPrincipal.FindFirst("UserToken")?.Value; | |
var userToken = Guid.Parse(userTokenString); | |
// Instantiate a view model with data from Redis. | |
var viewModel = new MyProfileIndexViewModel | |
{ | |
BasicData = await _customerDataService.GetBasicDataAsync(userToken), | |
SupportInquiries = await _customerDataService.GetSupportInquiriesAsync(userToken, 0, 20) | |
}; | |
// TODO: Handle a situation where no data exists. | |
return View(viewModel); | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
using StackExchange.Redis; | |
public static class ObjectExtensions | |
{ | |
/// <summary> | |
/// Converts a simple object to a list of Redis Hash entries. | |
/// All object properties is casted to strings that can be stored in Redis. | |
/// </summary> | |
public static HashEntry[] ToHashEntries(this object obj) | |
{ | |
PropertyInfo[] properties = obj.GetType().GetProperties(); | |
var entries = properties | |
.Where(x => IsSupportedType(x.PropertyType) && x.GetValue(obj) != null) | |
.Select(property => new HashEntry(property.Name, property.GetValue(obj) | |
.ToString())).ToArray(); | |
return entries; | |
} | |
/// <summary> | |
/// Instantiates an object of T, by matching a list of hash entries to the object properties. | |
/// </summary> | |
public static T ConvertFromRedis<T>(this HashEntry[] hashEntries) | |
{ | |
PropertyInfo[] properties = typeof(T).GetProperties(); | |
var obj = Activator.CreateInstance(typeof(T)); | |
foreach (var property in properties) | |
{ | |
HashEntry entry = hashEntries.FirstOrDefault(g => g.Name.ToString().Equals(property.Name)); | |
if (entry.Value.IsNull) | |
{ | |
continue; | |
} | |
property.SetValue(obj, Convert.ChangeType(entry.Value.ToString(), property.PropertyType)); | |
} | |
return (T)obj; | |
} | |
private static bool IsSupportedType(Type type) | |
{ | |
var typeInfo = type.GetTypeInfo(); | |
// Primitive types, structs or strings are supported. | |
// Classes are not. They will need to be stored as separate hashes. | |
return typeInfo.IsPrimitive || typeInfo.IsValueType || type == typeof(string); | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
public class Startup | |
{ | |
public Startup(IHostingEnvironment env) | |
{ | |
var builder = new ConfigurationBuilder() | |
.SetBasePath(env.ContentRootPath) | |
.AddJsonFile("appsettings.json", optional: false, reloadOnChange: true) | |
.AddJsonFile($"appsettings.{env.EnvironmentName}.json", optional: true) | |
.AddEnvironmentVariables(); | |
Configuration = builder.Build(); | |
} | |
public IConfigurationRoot Configuration { get; } | |
// This method gets called by the runtime. Use this method to add services to the container. | |
public void ConfigureServices(IServiceCollection services) | |
{ | |
services.AddAuthorization(options => | |
{ | |
// Add authorization rule for logged in users. | |
options.AddPolicy("LoggedInUser", | |
policy => policy.RequireAssertion(context => | |
context.User.HasClaim(c => | |
c.Type == "UserToken" && | |
c.Issuer == "http://testsite.local"))); | |
}); | |
// Add framework services. | |
services.AddMvc(); | |
} | |
// This method gets called by the runtime. Use this method to configure the HTTP request pipeline. | |
public void Configure(IApplicationBuilder app, IHostingEnvironment env, ILoggerFactory loggerFactory) | |
{ | |
loggerFactory.AddConsole(Configuration.GetSection("Logging")); | |
loggerFactory.AddDebug(); | |
if (env.IsDevelopment()) | |
{ | |
app.UseDeveloperExceptionPage(); | |
app.UseBrowserLink(); | |
} | |
else | |
{ | |
app.UseExceptionHandler("/Home/Error"); | |
} | |
app.UseStaticFiles(); | |
app.UseCookieAuthentication(new CookieAuthenticationOptions() | |
{ | |
AuthenticationScheme = CookieAuthenticationDefaults.AuthenticationScheme, | |
LoginPath = new PathString("/Account/Unauthorized/"), | |
AccessDeniedPath = new PathString("/Account/Forbidden/"), | |
AutomaticAuthenticate = true, | |
AutomaticChallenge = true, | |
Events = new CookieAuthenticationEvents | |
{ | |
OnValidatePrincipal = CustomPrincipalValidator.ValidateAsync | |
} | |
}); | |
app.UseMvc(routes => | |
{ | |
routes.MapRoute( | |
name: "default", | |
template: "{controller=Home}/{action=Index}/{id?}"); | |
}); | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment