Problem
How to implement custom model binders in ASP.NET Core.
Description
In an earlier post, I discussed how to prevent insecure object references by encrypting the internal references (e.g. table primary keys) using Data Protection API. To avoid duplication of code that encrypts/decrypts on every controller, I used filters in that example. In this post, I’ll use another complimentary technique: custom model binding.
What we want to achieve is,
- Encrypt data going out to views; using result filters.
- Decrypt as it comes back to controllers; using custom model binding.
Note
if you’re new to Model Binding, Data Protection API or Filters, please read earlier posts first.
Solution
Create a marker interface and attribute to flag properties on your model as protected, i.e., require encryption/decryption.
- public interface IProtectedIdAttribute { }
-
- public class ProtectedIdAttribute
- : Attribute, IProtectedIdAttribute { }
Create a custom model binder by implementing IModelBinder interface.
- public class ProtectedIdModelBinder : IModelBinder
- {
- private readonly IDataProtector protector;
-
- public ProtectedIdModelBinder(IDataProtectionProvider provider)
- {
- this.protector = provider.CreateProtector("protect_my_query_string");
- }
-
- public Task BindModelAsync(ModelBindingContext bindingContext)
- {
- var valueProviderResult =
- bindingContext.ValueProvider.GetValue(bindingContext.ModelName);
-
- if (valueProviderResult == ValueProviderResult.None)
- return Task.CompletedTask;
-
- bindingContext.ModelState.SetModelValue(
- bindingContext.ModelName, valueProviderResult);
-
- var result = this.protector.Unprotect(valueProviderResult.FirstValue);
-
- bindingContext.Result = ModelBindingResult.Success(result);
- return Task.CompletedTask;
- }
- }
Create a custom model binder provider by implementing IModelBinderProvider interface.
- public class ProtectedIdModelBinderProvider : IModelBinderProvider
- {
- public IModelBinder GetBinder(ModelBinderProviderContext context)
- {
- if (context.Metadata.IsComplexType) return null;
-
- var propName = context.Metadata.PropertyName;
- if (propName == null) return null;
-
- var propInfo = context.Metadata.ContainerType.GetProperty(propName);
- if (propInfo == null) return null;
-
- var attribute = propInfo.GetCustomAttributes(
- typeof(IProtectedIdAttribute), false).FirstOrDefault();
- if (attribute == null) return null;
-
- return new BinderTypeModelBinder(typeof(ProtectedIdModelBinder));
- }
- }
Add your custom model binder provider to MVC services in Startup class.
- public void ConfigureServices(
- IServiceCollection services)
- {
- services.AddDataProtection();
-
- services.AddMvc(options =>
- {
- options.ModelBinderProviders.Insert(
- 0, new ProtectedIdModelBinderProvider());
- });
- }
Add your [ProtectedId] attribute to models.
- public class MovieInputModel
- {
- [ProtectedId]
- public string Id { get; set; }
- }
-
- public class MovieViewModel
- {
- [ProtectedId]
- public string Id { get; set; }
- public string Title { get; set; }
- public int ReleaseYear { get; set; }
- public string Summary { get; set; }
- }
Add a controller to use the models.
- public class HomeController : Controller
- {
- public IActionResult Index()
- {
- List<MovieViewModel> model = GetMovies();
- return View(model);
- }
-
- public IActionResult Details(MovieInputModel model)
- {
- return Content(model.Id);
- }
Views will show encrypted identifiers.
But our model binder will decrypt it.
Discussion
As we discussed in the previous post Model Binding is the mechanism through which ASP.NET Core maps HTTP requests to our models. In order to achieve this mapping, the framework will go through a list of providers that will indicate whether they can handle the mapping or not. If they can, they will return a binder that is responsible for actually doing the mapping.
Model Binding
In order to tell if the framework that we need needs some bespoke mapping; i.e., decryption of incoming data, we create our own provider and binder.
Model Binder Provider will decide when our custom binder is needed. In our solution, the provider is simply checking the existence of our attribute/interface on the model property and if it exists, it will return our custom binder.
Note
BinderTypeModelBinder is used here since our custom binder has dependencies it needs at runtime. Otherwise, you could just return an instance of your custom binder.
Model Binder will actually do the mapping (i.e. decryption in our case) of incoming data. In our solution we get the value being passed, decrypt it using Data Protection API and set the decrypted value as the binding result.
Result Filter
In order to automatically encrypt the properties of our model, I wrote a simple result filter.
- public class ProtectedIdResultFilter : IResultFilter
- {
- private readonly IDataProtector protector;
-
- public ProtectedIdResultFilter(IDataProtectionProvider provider)
- {
- this.protector = provider.CreateProtector("protect_my_query_string");
- }
-
- public void OnResultExecuting(ResultExecutingContext context)
- {
- var viewResult = context.Result as ViewResult;
- if (viewResult == null) return;
-
- if (!typeof(IEnumerable).IsAssignableFrom(viewResult.Model.GetType()))
- return;
-
- var model = viewResult.Model as IList;
- foreach (var item in model)
- {
- foreach (var prop in item.GetType().GetProperties())
- {
- var attribute =
- prop.GetCustomAttributes(
- typeof(IProtectedIdAttribute), false).FirstOrDefault();
-
- if (attribute != null)
- {
- var value = prop.GetValue(item);
- var cipher = this.protector.Protect(value.ToString());
- prop.SetValue(item, cipher);
- }
- }
- }
- }
-
- public void OnResultExecuted(ResultExecutedContext context)
- {
-
- }
- }
-
- public class ProtectedIdResultFilterAttribute : TypeFilterAttribute
- {
- public ProtectedIdResultFilterAttribute()
- : base(typeof(ProtectedIdResultFilter))
- { }
- }
This filter is added globally in Startup class.
- public void ConfigureServices(
- IServiceCollection services)
- {
- services.AddDataProtection();
-
- services.AddMvc(options =>
- {
- options.ModelBinderProviders.Insert(
- 0, new ProtectedIdModelBinderProvider());
- options.Filters.Add(typeof(ProtectedIdResultFilter));
- });
- }
Source Code