ASP.NET MVC Application Building: Forums #5 – Membership
In this series of blog posts, I build an entire ASP.NET MVC Forums application from start to finish. In this post, I explain how to test and implement authentication and authorization for the Forums application.
Before you read this blog post, you should read the previous posts in this series:
ASP.NET MVC Application Building: Forums #1 – Create the Perfect Application – In this first entry, I explain the overall goals of the ASP.NET MVC Forums application. I emphasize the importance of Software Design Principles and justify my choice to use test-driven development.
ASP.NET MVC Application Building: Forums #2 – Create the First Unit Test – In the second entry, I build the first unit test and create the Index() action which returns a collection of messages.
ASP.NET MVC Application Building: Forums #3 – Post Messages – In the third entry, I add the unit tests and functionality required to post new messages and replies.
What about the fourth entry in this series? The fourth (and fourth ½ posts) were devoted to the subject of validation. I keep revisiting this topic because it is so important to get it right. In the first half of this post, I revisit the issue of validation (yet again). In the second half of this post, I address the issue of authenticating and authorizing users.
Validation Revisited, Again
I decided to modify the Forums application so that it uses the validation attributes from the System.ComponentModel.DataAnnotations namespace. These are the same set of validation attributes that are used in ASP.NET Dynamic Data applications. In order to use these attributes, you must have Visual Studio Service Pack 1 installed and you must add a reference to the System.ComponentModel.DataAnnotations assembly (located in the Global Assembly Cache).
If you want to learn more about using these attributes, please read the following blog post:
Right now, my validation needs are minor. I need to validate that when a user posts a new message, the user supplies both a message subject and message body. In order to perform this type of validation, I can take advantage of the Data Annotations Required attribute in my Message class. The modified Message class in Listing 1 uses the Required attribute.
Listing 1 – Message.cs
using System; using MvcValidation; using System.Collections.Generic; using System.Data.Linq; using System.Web.Mvc; using System.ComponentModel.DataAnnotations; namespace MvcForums.Models.Entities { public class Message { private DateTime _entryDate = DateTime.Now; public Message() { } public Message(int id, int? parentThreadId, int? parentMessageId, string author, string subject, string body) { this.Id = id; this.ParentThreadId = parentThreadId; this.ParentMessageId = parentMessageId; this.Author = author; this.Subject = subject; this.Body = body; } public int Id { get; set; } public int? ParentThreadId { get; set; } public int? ParentMessageId { get; set; } public string Author { get; set; } [Required(ErrorMessage="You must enter a message subject.")] public string Subject { get; set; } [Required(ErrorMessage="You must enter a message body.")] public string Body { get; set; } public DateTime EntryDate { get { return _entryDate; } set { _entryDate = value; } } } }
Notice that I have supplied an ErrorMessage for both of the Required attributes.
I modified the ForumRepository class so that it accepts a class that implements the IValidation interface in its constructor. In other words, a particular validation framework is injected into the ForumRepository class using dependency injection.
I modified the AddMessage() method in my ForumsRepository class so that it calls _validation.Validate() before inserting a new Forums message into the database. The new version of the ForumRepository class is contained in Listing 2.
Listing 2 – Models\ForumRepository.cs
using MvcFakes; using System.Linq; using System.Data.Linq; using LinqToSqlExtensions; using Microsoft.Web.Mvc; using System.Collections.Generic; using MvcForums.Models.Entities; using MvcValidation; namespace MvcForums.Models { public class ForumRepository : IForumRepository { private IDataContext _dataContext; private IValidation _validation; public ForumRepository() : this(new DataContextWrapper("conForumsDB", "~/Models/ForumsDB.xml"), new Validation()) { } public ForumRepository(IDataContext dataContext, IValidation validation) { _dataContext = dataContext; _validation = validation; } public IList<Message> SelectThreads() { var messages = _dataContext.GetTable<Message>(); var threads = from m in messages where m.ParentThreadId == null select m; return threads.ToList(); } public IList<Message> SelectMessages(int threadId) { var messages = _dataContext.GetTable<Message>(); var threads = from m in messages where (m.Id == threadId || m.ParentThreadId == threadId) select m; return threads.ToList(); } public Message AddMessage(Message messageToAdd) { _validation.Validate(messageToAdd); _dataContext.Insert(messageToAdd); return messageToAdd; } } }
If any of the Data Annotations validation attributes are invalid (the subject or body is missing) then the Validation.Validate() method throws a ValidationIssueException.
Finally, I changed the ForumController class so that it catches a ValidationIssueException. The modified ForumController class is contained in Listing 3.
Listing 3 – Controllers\ForumController.cs
using System; using System.Web.Mvc; using MvcForums.Models; using Microsoft.Web.Mvc; using MvcForums.Models.Entities; using MvcValidation; namespace MvcForums.Controllers { public class ForumController : Controller { private IForumRepository _repository; public ForumController() : this(new ForumRepository()) { } public ForumController(IForumRepository repository) { _repository = repository; } public ActionResult Index() { ViewData.Model = _repository.SelectThreads(); return View("Index"); } [AcceptVerbs("GET")] public ActionResult Create() { return View("Create"); } [AcceptVerbs("Post")] public ActionResult Create(FormCollection form) { var messageToCreate = new Message(); try { UpdateModel(messageToCreate, new[] { "Author", "ParentThreadId", "ParentMessageId", "Subject", "Body" }); _repository.AddMessage(messageToCreate); } catch (ValidationIssueException vex) { ViewData.ModelState.CopyValidationIssues(vex); return View("Create", messageToCreate); } catch { return View("Create", messageToCreate); } // Redirect return RedirectToAction("Index"); } public ActionResult Thread(int threadId) { ViewData.Model = _repository.SelectMessages(threadId); return View("Thread"); } } }
Let’s examine the Create(FormCollection form) method in more detail. This method starts by creating a new instance of the Message class. Next, it calls the UpdateModel() method to copy the fields from the XHTML form to the instance of the Message class. Notice that the UpdateModel() method uses a whitelist of form field names to copy (You wouldn't want a sneaky hacker overriding the UserName property). If the UpdateModel() method encounters issues then it updates the ModelState and throws an InvalidOperationException.
Next, the ForumRespository.AddMessage() method is called. Remember that this method calls Validation.Validate() internally. If the Validate() method raises an exception, the exception is caught by the Catch clause for the ValidationIssueException.
The ValidationIssueException Catch clause uses the CopyValidationIssues() extension method on the ViewData.Model class to copy all of the validation issues represented by the ValidationIssueException class into ModelState. When the Create view is redisplayed, the error messages from the Data Annotations attributes are displayed (see Figure 1).
Figure 1 – Validation issues bubble up from the Required validation attributes
I had to create several support classes to get the Data Annotations validation attributes to work with the Forums application. These classes are all contained in a separate project (included with the download) named MvcValidation. The MvcValidation project includes the following classes:
· IValidation – Represents the contract that all validation providers must support.
· Validation – Implements the Validate() method that executes all of the Data Annotations validation attributes on a class.
· ValidationIssue – Represents one validation issue.
· ValidationIssueException – Represents the exception that is thrown when there is at least one validation issue.
· ModelStateDictionaryExtensions – Adds the CopyValidationIssues() method to the ViewData.ModelState class.
After I made all of these modifications, my original unit tests for validation continued to run successfully. These two tests are contained in the Controllers\ForumsControllerTest.cs class and look like this:
[TestMethod] public void EmptySubjectFailsValidation() { // Arrange var controller = new ForumController(_repository); // Act var form = new NameValueCollection(); form.Add("author", "Stephen"); form.Add("subject", String.Empty); form.Add("body", "Body of new thread"); controller.ControllerContext = new FakeControllerContext(controller, form); var result = (ViewResult)controller.Create(new FormCollection()); // Assert var modelState = result.ViewData.ModelState; Assert.IsFalse(result.ViewData.ModelState.IsValid); Assert.AreEqual("You must enter a message subject.", modelState["subject"].Errors[0].ErrorMessage); } [TestMethod] public void EmptyBodyFailsValidation() { // Arrange var controller = new ForumController(_repository); // Act var form = new NameValueCollection(); form.Add("author", "Stephen"); form.Add("subject", "New Message"); form.Add("body", String.Empty); controller.ControllerContext = new FakeControllerContext(controller, form); var result = (ViewResult)controller.Create(new FormCollection()); // Assert var modelState = result.ViewData.ModelState; Assert.IsFalse(modelState.IsValid); Assert.AreEqual("You must enter a message body.", modelState["body"].Errors[0].ErrorMessage); }
The first test verifies that an empty subject form field causes ModelState to contain the error message “You must enter a message subject.”. The second test verifies that an empty body form field causes ModelState to contain the error message "You must enter a message body.".
Requiring Authentication
We want to make sure that only authenticated users can post a message. We require that people register and login before posting to the forums.
Since we are being virtuous about test-driven development, we should express this intention with a test. The test in Listing 4 verifies that when an anonymous user executes the Index() action, an HTTP 401 Status Code is returned. The HTTP 401 Status Code means that the user is unauthorized (see http://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html).
Listing 4 – AnonymousUserIsRedirectedTest
[TestMethod] public void AnonymousUserIsRedirected() { // Arrange var controller = new ForumController(_repository); var fakeContext = new FakeControllerContext(controller); // Act controller.ActionInvoker.InvokeAction(fakeContext, "Index"); int statusCode = fakeContext.HttpContext.Response.StatusCode; // Assert Assert.AreEqual(401, statusCode); }
Notice that the test in Listing 4 calls the Controller.ActionInvoker.InvokeAction() method to execute the Index() action. If you want the attributes associated with a controller action to execute then you need to call InvokeAction(). If you just call controller.Index() then any attributes applied to the Index() action are ignored.
In order to call ActionInvoker.InvokeAction(), we needed to fake the ControllerContext. I faked the ControllerContext by taking advantage of my MvcFakes project.
When you first run the test, it will fail (see Figure 2). You want the test to fail because only a failing tests provides you with permission to modify your application code.
Figure 2 – Failing test
We can satisfy the unit test by adding an [Authorize] attribute to the ForumsController. When you add an [Authorize] attribute to a controller class, the attribute is applied to all of the controller actions automatically. The modified ForumController class is declared like this:
[Authorize] public class ForumController : Controller { …. }
After we add the [Authorize] attribute, our test passes and we can breathe a sigh of relief.
We next need to make sure that a user’s user name is added to the database when the user submits a new forum post. Therefore, we need another test. The test in Listing 5 verifies when a user named Kermit posts a new message then the Message.Author property has the value Kermit.
Listing 5 – PostHasUserName Test
[TestMethod] public void PostHasUserName() { // Arrange var controller = new ForumController(_repository); // Act var form = new NameValueCollection(); form.Add("subject", "New Thread!"); form.Add("body", "Body of new thread"); var fakeContext = new FakeControllerContext(controller, "Kermit", form); controller.ControllerContext = fakeContext; controller.Create(new FormCollection()); // Assert var threads = _repository.SelectThreads(); var lastThread = threads.Last(); Assert.AreEqual("Kermit", lastThread.Author); }
The test in Listing 5, once again, takes advantage of the MvcFakes project to create a fake ControllerContext. This ControllerContext represents the user name Kermit and a set of form parameters. The test verifies that when the Create() action is invoked and a new Forums message is created that the new Message.Author property is equal to the value Kermit.
In order to pass this new test, we need to make two simple modifications to the Create() action in the FormController class. The modified Create() action is contained in Listing 6.
Listing 6 – CreateAction() (with user name)
[AcceptVerbs("Post")] public ActionResult Create(FormCollection form) { var messageToCreate = new Message(); messageToCreate.Author = User.Identity.Name; try { UpdateModel(messageToCreate, new[] { "ParentThreadId", "ParentMessageId", "Subject", "Body" }); _repository.AddMessage(messageToCreate); } catch (ValidationIssueException vex) { ViewData.ModelState.CopyValidationIssues(vex); return View("Create", messageToCreate); } catch { return View("Create", messageToCreate); } // Redirect return RedirectToAction("Index"); }
In Listing 6, I removed Author from the whitelist of form parameter names used by the UpdateModel() method. We don’t want to retrieve the message author from an XHTML form parameter. Instead, the value of the Author property is retrieved from User.Identity.Name.
I debated about exactly where to assign the user name to the Message.Author property. I was tempted to do it in the constructor for the Message class. However, I don’t want to couple my Message class to the HttpContext. I want to make sure that I can use my model classes with any front end rendering technology including Silverlight, Windows Forms, or a WCF service. Therefore, I assigned the user name to the Message.UserName property in the controller action.
After I made these changes to the Create() action, all of the tests pass (see Figure 4). I’ve now implemented basic authentication. In the future, if I modify my code, I can be reassured that I know when authentication is working and when it is not.
Figure 4 – Success!
As a sanity check, I like to actually run my MVC application occasionally. Fortunately, we don’t need to create a Login and Register view because the Visual Studio MVC project supplies an AccountController controller, Login view, and Register view for us.
When we run the MVC Forums application, we get the view in Figure 5.
Figure 5 – The Login page
Notice that the Login view includes a link to register. If you click this link, you get the view in Figure 4.
Figure 4 – Registration page
You can complete the form in Figure 4 to create a new account. The Account controller takes advantage of a database named AspNetDB to store user names and passwords. This database is located in the App_Data folder. You can, of course, configure the ASP.NET Membership provider to store the account information in some other database by modifying settings in the Web configuration (web.config) file.
After you register or login, you are redirected to the URL /Home/Index. Our Forums application does not have a HomeController. Therefore, we need to modify the AccountController so that it redirects to the Index action of the ForumController. You can do a quick search and replace in the Account controller to correct his behavior.
Summary
This blog entry had two parts. In the first part, I demonstrated how you can perform form validation in an MVC application by taking advantage of the Data Annotations validation attribute classes. We added attributes to our Message class to mark both the Subject and Body properties as required.
Next, I demonstrated how you can create unit tests to test authentication. We created a unit test that verifies that unauthorized users cannot invoke a controller action. We also created a unit test that verifies that a person’s user name is added to the database when the person submits a new message.
We still have more work to do! In the next blog entry, I need to clean up the views for our forums application. I want to make sure that you can see the list of messages, start a new thread, and reply to an existing message. We also should introduce master pages and partials to make easier to maintain our views.