ASP.NET MVC + CAS

Background

The ASP.NET authentication process occurs at a very low level in the request processing pipeline.  The vast majority of the operations of the FormsAuthenticationModule and CasAuthenticationModule occur before the RequestHandler executes (and in some cases prevent the handler from executing at all).  Both ASP.NET WebForms and ASP.NET MVC plug in at the RequestHandlerExecute stage.  Therefore, the differences between CASifying a WebForms application and an MVC application are minor & very subtle.  From a configuration perspective, the procedure is identical for both frameworks with the exception of the fact that URL Authorization is not necessary/appropriate for MVC actions.

The default ASP.NET MVC 2 Web Application includes boilerplate code for interfacing with Forms Authentication and a membership provider of your choosing.  For the purposes of this example, we'll simply CASify this code.  Membership features will be removed from the application (registering, changing passwords, etc.) as they are unrelated to CAS.  If you decide to implement these features, you will generally want to operate on the underlying data store that CAS uses to authenticate users (LDAP, Active Directory, etc.).

The only code-related changes to the boilerplate MVC 2 app code involves adjusting the LogOn and LogOff actions. 

LogOn Action

Without CAS, the LogOn action would typically accept anonymous GET requests and return a LogOn view.  That view would contain fields for collecting a username, password, and maybe a 'Remember me' option.  The post back of that view would trigger validation and would verify the credentials from some back-end.  With CAS in the picture, all you need to do is decorate the LogOn method with the [Authorize] attribute (causing ASP.NET to redirect anonymous requests out to the CAS server) and return a RedirectToAction that kicks the user back to the Home Page (causing ASP.NET to redirect authenticated requests back to the home page). 

In case you care, here's how the process works under-the-hood:

  • When an anonymous user attempts to execute the LogOn action, the [Authorize] attribute causes the MVC framework to ensure that HttpContext.Current.User.Identity.IsAuthenticated is true.  
    • If HttpContext.Current.User.Identity.IsAuthenticated is true...
      • The action executes, redirecting the user to the Home action & view.
    • If HttpContext.Current.User.Identity.IsAuthenticated is not true...
      • The CasAuthenticationModule steps in (just as it would for a URL authorization rule denying anonymous users in a WebForms app) and redirects the request out to the CAS Login page (which can still be configured to use Gateway or Renew options via web.config). 
      • The CAS server verifies the credentials and redirects back to the LogOn action with a ticket parameter in the URL.
      • The CasAuthenticationModule steps in before the LogOn action code, sees the ticket in the URL and the TicketValidator connects back to the CAS server to verify the service ticket is valid.  Unless there is a malicious user or a configuration problem, the ticket validates. 
      • Assume the ticket validates successfully.  The CasAuthenticationModule sets the context.User and Thread.CurrentPrincipal to a CasPrincipal for the current request.  It also drops a FormsAuthenticationCookie containing a FormsAuthenticationTicket to the client which will be detected by the CasAuthenticationModule and used to authenticate subsequent requests.
      • The MVC RequestHandler executes.  This time, the [Authorize] attribute passes the test (HttpContext.Current.User.Identity.IsAuthenticated is true).  The code in the LogOn action returns a RedirectToAction which redirects the user back to the Home action & view.
      • Prior to the Home action executing, the CasAuthenticationModule detects & validates the FormsAuthenticationCookie and FormsAuthenticationTicket and repopulates the context.User and Thread.CurrentPrincipal with the CasPrincipal.
      • The Home view now shows that the user is logged in and shows the username.

LogOff Action

The LogOff action calls FormsAuthentication.SignOut() method by way of a FormsAuthenticationService class that implements an IFormsAuthenticationService.  The Service class is there to facilitate unit testing.  The example replaces the FormsAuthentication.SignOut() call with CasAuthentication.SingleSignOut().  The CasAuthentication.SingleSignOut() method removes the FormsAuthenticationCookie and behaves just like FormsAuthentication.SignOut(), but it also causes a transparent round-trip out to the CAS server telling it to log the user out of other services.  The CAS server then attempts to explicily terminate the session for any web application that was SingleSignOn'd to by the user.  Depending on the configuration of each of the clients & applications, this may/may not work across the board.  However, subsequent gateway authentication requests will fail, subsequent SingleSignOn attempts will fail, and the user will need their credentials to access any other services.

For SingleSignOut() to work transparently (i.e., without displaying a logout page on the CAS server), you will need to configure the logoutController bean in WEB-INF/cas-servlet.xml and add the following property: p:followServiceRedirects="true".

Step-By-Step Procedure

In Visual Studio 2010, select File, New Project, Visual C#, Web, ASP.NET MVC 2 Web Application.  This creates a solution with 2 projects in it.  Click Yes to create a Unit Test project.  This creates a simple ASP.NET MVC web application with a few static pages and a few pages related to authentication and membership.

  • Configure the application's web.config as in the ExampleWebSite project.  The configuration process is the same as with WebForms projects.  The only exception is that instead of using configuration/location/authorization/deny rules for MVC actions/views, you simply need to add the Authorize attribute to any action that requires authorization.
  • Expand Views and delete the entire Account directory. 
  • Expand Controllers and open AccountController.cs.
    • Delete the following property and remove the related line in the constructor
      • public IMembershipService MembershipService { get; set; }
      • if (MembershipService == null) { MembershipService = new AccountMembershipService(); }
    • Change the LogOn and LogOff action methods to look like this:
[Authorize]
public ActionResult LogOn()
{
  return RedirectToAction("Index", "Home");
}

public ActionResult LogOff()
{
  FormsService.SignOut();
  return RedirectToAction("Index", "Home");
}
    • Delete the following methods:
      • Register()
      • Register(RegisterModel model)
      • ChangePassword()
      • ChangePassword(ChangePasswordModel model)
      • ChangePasswordSuccess()
  • Expand Models and edit AccountModels.cs
    • Remove the ChangePasswordModel class.
    • Remove the RegisterModel class.
    • Remove the IMembershipService interface.
    • Remove the AccountMembershipService class.
    • Remove the AccountValidation class.
    • Remove the PropertiesMustMatchAttribute class.
    • Remove the ValidatePasswordLengthAttribute class.
  • In Test project, expand Controllers and select AccountControllerTest.cs
    • Delete the following methods:
      • ChangePassword_Get_ReturnsView()
      • ChangePassword_Post_ReturnsRedirectOnSuccess()
      • ChangePassword_Post_ReturnsViewIfChangePasswordFails()
      • ChangePassword_Post_ReturnsViewIfModelStateIsInvalid()
      • ChangePasswordSuccess_ReturnsView()
      • Register_Get_ReturnsView()
      • Register_Post_ReturnsRedirectOnSuccess()
      • Register_Post_ReturnsViewIfRegistrationFails
      • Register_Post_ReturnsViewIfModelStateIsInvalid
    • Remove the MockMembershipService class.
    • Remove the MembershipService creation from the controller item in GetAccountController()

The general gist of it is to make sure your web.config is setup properly, add [Authorize] to the LogOn action method, replace FormsAuthentication.SignOut() with CasAuthentication.SingleSignOut(), and strip out all of the Membership stuff until the solution builds.  If I missed something, the build will fail in which case you can double click on the error message and just delete the line/method/etc. that I missed.  If I've missed any spots above, please post a message to cas-dev and I'll make the adjustments.