Skip to content

Instantly share code, notes, and snippets.

@stefanolsen
Last active August 2, 2017 20:27
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save stefanolsen/1a110f92c1984c0fc8dedba7020b8f79 to your computer and use it in GitHub Desktop.
Save stefanolsen/1a110f92c1984c0fc8dedba7020b8f79 to your computer and use it in GitHub Desktop.
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");
}
}
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");
}
}
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");
}
}
}
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; }
}
[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);
}
}
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);
}
}
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