Securing an ASP.NET MVC 2 Application
This post attempts to look at some of the methods that can be used to secure an ASP.NET MVC 2 Application called Northwind Traders Human Resources.
The sample code for the project is attached at the bottom of this post.
We are going to use a slightly modified Northwind database. The screen capture from SQL server management studio shows the change. I added a new column called Salary, inserted some random salaries for the employees and then turned off AllowNulls.
The reporting relationships for Northwind Employees is shown below.
The requirements for our application are as follows:
- Employees can see their LastName, FirstName, Title, Address and Salary
- Employees are allowed to edit only their Address information
- Employees can see the LastName, FirstName, Title, Address and Salary of their immediate reports
- Employees cannot see records of non immediate reports.
- Employees are allowed to edit only the Salary and Title information of their immediate reports.
- Employees are not allowed to edit the Address of an immediate report
- Employees should be authenticated into the system. Employees by default get the “Employee” role. If a user has direct reports, they will also get assigned a “Manager” role. We use a very basic empId/pwd scheme of EmployeeID (1-9) and password test$1. You should never do this in an actual application.
- The application should protect from Cross Site Request Forgery (CSRF). For example, Michael could trick Steven, who is already logged on to the HR website, to load a page which contains a malicious request. where without Steven’s knowledge, a form on the site posts information back to the Northwind HR website using Steven’s credentials. Michael could use this technique to give himself a raise :-)
UI Notes
The layout of our app looks like so:
When Nancy (EmpID 1) signs on, she sees the default page with her details and is allowed to edit her address.
If Nancy attempts to view the record of employee Andrew who has an employeeID of 2 (Employees/Edit/2), she will get a “Not Authorized” error page.
When Andrew (EmpID 2) signs on, he can edit the address field of his record and change the title and salary of employees that directly report to him.
Implementation Notes
All controllers inherit from a BaseController. The BaseController currently only has error handling code.
When a user signs on, we check to see if they are in a Manager role. We then create a FormsAuthenticationTicket, encrypt it (including the roles that the employee belongs to) and add it to a cookie.
private void SetAuthenticationCookie(int employeeID, List<string> roles)
{
HttpCookiesSection cookieSection = (HttpCookiesSection) ConfigurationManager.GetSection("system.web/httpCookies");
AuthenticationSection authenticationSection = (AuthenticationSection) ConfigurationManager.GetSection("system.web/authentication");
FormsAuthenticationTicket authTicket =
new FormsAuthenticationTicket(
1, employeeID.ToString(), DateTime.Now, DateTime.Now.AddMinutes(authenticationSection.Forms.Timeout.TotalMinutes),
false, string.Join("|", roles.ToArray()));
String encryptedTicket = FormsAuthentication.Encrypt(authTicket);
HttpCookie authCookie = new HttpCookie(FormsAuthentication.FormsCookieName, encryptedTicket);
if (cookieSection.RequireSSL || authenticationSection.Forms.RequireSSL)
{
authCookie.Secure = true;
}
HttpContext.Current.Response.Cookies.Add(authCookie);
}
We read this cookie back in Global.asax and set the Context.User to be a new GenericPrincipal with the roles we assigned earlier.
protected void Application_AuthenticateRequest(Object sender, EventArgs e)
{
if (Context.User != null)
{
string cookieName = FormsAuthentication.FormsCookieName;
HttpCookie authCookie = Context.Request.Cookies[cookieName];
if (authCookie == null)
return;
FormsAuthenticationTicket authTicket = FormsAuthentication.Decrypt(authCookie.Value);
string[] roles = authTicket.UserData.Split(new char[] { '|' });
FormsIdentity fi = (FormsIdentity)(Context.User.Identity);
Context.User = new System.Security.Principal.GenericPrincipal(fi, roles);
}
}
MVC provides the Authorize attribute which is a type of Action Filter that runs around the execution of the method. The Authorize attribute is added to ensure that only authorized users can execute that Action. We add the AuthorizeToView attribute to any Action method that requires authorization.
[HttpPost]
[Authorize(Order = 1)]
//To prevent CSRF
[ValidateAntiForgeryToken(Salt = Globals.EditSalt, Order = 2)]
//See AuthorizeToViewIDAttribute class
[AuthorizeToViewID(Order = 3)]
[ActionName("Edit")]
public ActionResult Update(int id)
{
var employeeToEdit = employeeRepository.GetEmployee(id);
if (employeeToEdit != null)
{
//Employees can edit only their address
//A manager can edit the title and salary of their subordinate
string[] whiteList = (employeeToEdit.IsSubordinate) ? new string[] { "Title", "Salary" } : new string[] { "Address" };
if (TryUpdateModel(employeeToEdit, whiteList))
{
employeeRepository.Save(employeeToEdit);
return RedirectToAction("Details", new { id = id });
}
else
{
ModelState.AddModelError("", "Please correct the following errors.");
}
}
return View(employeeToEdit);
}
We use the TryUpdateModel with a white list to ensure that (a) an employee is able to edit only their Address and (b) that a manager is able to edit only the Title and Salary of a subordinate. This works in conjunction with the AuthorizeToViewIDAttribute.
The ValidateAntiForgeryToken attribute is added (with a salt) to avoid CSRF. The Order on the attributes specify the order in which the attributes are executed.
The Edit View uses the AntiForgeryToken helper to render the hidden token:
...
...
<% using (Html.BeginForm())
{%>
<%=Html.AntiForgeryToken(NorthwindHR.Models.Globals.EditSalt)%>
<%= Html.ValidationSummary(true, "Please correct the errors and try again.") %>
<div class="editor-label">
<%= Html.LabelFor(model => model.LastName) %>
</div>
<div class="editor-field">
...
...
Using the Authorize attribute alone is not sufficient. It only checks to see if the user is authorized or is in a certain role. It does not check to see if the user has permission to access a certain record.
Hence, we ensure that a user has permissions to view a record by creating a custom attribute AuthorizeToViewIDAttribute that inherits from ActionFilterAttribute. This action filter makes a call to ensure that the current user is authorized to view a certain record.
public class AuthorizeToViewIDAttribute : ActionFilterAttribute
{
IEmployeeRepository employeeRepository = new EmployeeRepository();
public override void OnActionExecuting(ActionExecutingContext filterContext)
{
if (filterContext.ActionParameters.ContainsKey("id") && filterContext.ActionParameters["id"] != null)
{
if (employeeRepository.IsAuthorizedToView((int)filterContext.ActionParameters["id"]))
{
return;
}
}
throw new UnauthorizedAccessException("The record does not exist or you do not have permission to access it");
}
}
Instead of extending the ActionFilterAttribute, you could extend the AuthorizeAttribute as described here.
In addition, Kazi Manzur Rashid suggests the following method:
public class CustomAuthorizeAttribute : FilterAttribute, IAuthorizationFilter
{
private readonly IEmployeeRepository employeeRepository = new EmployeeRepository();
public void OnAuthorization(AuthorizationContext filterContext)
{
ValueProviderResult result = filterContext.Controller.ValueProvider.GetValue("id");
if (result != null)
{
int id;
if (int.TryParse(result.AttemptedValue, out id) && IsAuthorized(id))
{
HttpCachePolicyBase cachePolicy = filterContext.HttpContext.Response.Cache;
cachePolicy.SetProxyMaxAge(new TimeSpan(0));
cachePolicy.AddValidationCallback(CacheValidateHandler, id);
return;
}
}
filterContext.Result = new RedirectResult("/Error/NotAuthorized");
}
private bool IsAuthorized(int id)
{
return employeeRepository.IsAuthorizedToView(id);
}
private void CacheValidateHandler(HttpContext context, object data, ref HttpValidationStatus validationStatus)
{
validationStatus = OnCacheAuthorization((int) data);
}
private HttpValidationStatus OnCacheAuthorization(int id)
{
bool isAuthorized = IsAuthorized(id);
return (isAuthorized) ? HttpValidationStatus.Valid : HttpValidationStatus.IgnoreThisRequest;
}
}
The application uses View specific models for ease of model binding. The View specific model contains just the properties required to render the view.
public class EmployeeViewModel
{
public int EmployeeID;
[Required]
[DisplayName("Last Name")]
public string LastName { get; set; }
[Required]
[DisplayName("First Name")]
public string FirstName { get; set; }
[Required]
[DisplayName("Title")]
public string Title { get; set; }
[Required]
[DisplayName("Address")]
public string Address { get; set; }
[Required]
[DisplayName("Salary")]
[Range(500, double.MaxValue)]
public decimal Salary { get; set; }
public bool IsSubordinate { get; set; }
}
To help with displaying readonly/editable fields, we use a helper method as shown below:
//Simple extension method to display a TextboxFor or DisplayFor based on the isEditable variable
public static MvcHtmlString TextBoxOrLabelFor<TModel, TProperty>(this HtmlHelper<TModel> htmlHelper,
Expression<Func<TModel, TProperty>> expression,
bool isEditable)
{
if (isEditable)
{
return htmlHelper.TextBoxFor(expression);
}
else
{
return htmlHelper.DisplayFor(expression);
}
}
The helper method is used in the view like so:
<%=Html.TextBoxOrLabelFor(model => model.Title, Model.IsSubordinate)%>
You can download the demo project below.
VS 2008, ASP.NET MVC 2 RTM
Remember to change the connectionString to point to your Northwind DB
Feedback and bugs are always welcome :-)