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:

ASP.NET MVC Tip #43 – Use Data Annotation Validators

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

clip_image002

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

clip_image004

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!

clip_image006

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

clip_image008

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

clip_image010

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.

Download the Code

13 Comments

  • I just wanted to say that I really like what you've done here.

    This implementation provides you with the option of doing simple validation (e.g. required fields, length checks, regex, etc.) while also allowing you to insert more complex validation (e.g. data integrity checks against your model).

    Quick question though: how would you handle validation that cannot be represented in your domain / model? For instance, if you require the user to click a checkbox to indicate they have read a disclaimer message. Obviously, this temporary information shouldn't be persisted to your domain or database, so would you put it on the controller level?

  • Another good post!

    You stated

    "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."

    As a general statement or rule, shouldn't controllers be expected to work with all the different front end rendering technologies also, with only the view being specific to a rendering technology?

    Or as developers, are we expected to write different controllers for all of the different types of applications we create?


  • @Stephen - great work on this series. :) I was planning on making this very project as a way to learn ASP.NET MVC and supporting bits - now I don't have to! ;)

    @Kevin - that sounds more like a client-side concern to me - but if you have to validate this on the server, then yes - I would put this in the controller. Not sure if that's the "right" way to do to, but since it wouldn't hit a repository I'm not sure where else such a concern would go.

  • Hello Sir,
    MVC architecture looks like really good, but it can be implemented on VS2008, so is it possible to implement MVC architecture in VS2005?????
    If yes then plz guide me...

    Jackson C.

  • @Jackson -- You can implement MVC on ASP.NET 2.0 -- but it is not recommended! See this blog post by another member on my team, Scott Hanselman:
    http://www.hanselman.com/blog/DeployingASPNETMVCOnASPNET20.aspx

  • Unit tests, repository pattern, flexible validation interface - great stuff. Perhaps the most complete tutorial on ASP.NET MVC I've seen to date. I look forward to implementing some of these approaches in my projects.

  • I think the membership is very simple,Why don't we write a new member-management system or create some classes inherits from membership to provide more complex functions?

    BTW:Your post is very good,we all like your posts very much,thank you very much!*^_^*

  • @Stephen: What is this MvcFakes dll that you have included in the solution, to this example? Will this be baked into the Mvc v1 product?

  • @pure krome -- Great question! No, the MvcFakes project is not part of the MVC framework. I describe this project here:

    http://weblogs.asp.net/stephenwalther/archive/2008/06/30/asp-net-mvc-tip-12-faking-the-controller-context.aspx

    This project keeps evolving as I discover new needs. For example, I recently added support for testing the Cache with the MvcFakes project.

  • One thing I noticed is that you are requiring the user to supply authentication credentials in clear text over the wire.

    Can you provide some guidance as to how to force SSL sessions with the ASP.NET MVC framework?

    Thanks

    Roger Williams

  • @Roger -- Good point! Yes, I need to address the issue of using routing with SSL.

  • Hey Stephen, thanks for the great work. Quick question - I have Preview 5 installed, but it doesn't seem to be compatible w/the MvcFakes project. Is there an update for these bits available anywhere?
    Thanks again,
    Bob

  • Was doing some r&d on asp.net mvc membership and came across forums#5. Is the download only for the membership piece or the entire app ? At this point, I only need a Login screen which allows CRUD from a table. Is the
    AspNetDB included as well ? thanks





Comments have been disabled for this content.