Sunday, September 18, 2011

Using cookies to control return page after login on asp.net mvc 3

Default behavior and the scenario

On asp.net in general, when an user tries to access a page that requires the user to login first, it is fairly common to use the built in support to return to that page after the user logins.

When an user tries to access the page and an authorization made by the code determines the user doesn’t have access, a 401 is returned by that code. When that happens the FormsAuthenticationModule intercepts it and instead redirects the user to the login page configured in the asp.net config.

When the redirect to the login page is sent,  it includes a returnUrl query string parameter. On a successful the user is sent back to that url. If no return url is indicated, the user is sent to a default url after authentication.

The above works fine, if the scenario fits well with the site.

One consideration is with the use of register and forgot password options, in which case extra code needs to be put in place if you want the user to be redirected to the url visited when the process started. A similar case is if the user visits any other page linked there, like a faq or about page and then hitting the login option you almost certainly have in your master page/layout.

Another consideration comes with the user of ajax based login forms accessible anywhere on the site. Depending on the site, the user might be on a page where you don’t want it redirect to. You still might want it redirected to a page the user visited before doing some action, like in a client’s scenario, a specific deal page.

The cookie based approach

One way to handle the above scenario is by storing the returnUrl in a cookie. By doing so the user can visit other pages (like the register/forgot password options), and the redirect will go to the last relevant page. Additionally you can control which actions are considered relevant pages, in case the user uses the login option in your master page/layout in any random page they happen to be at.

To set the cookie in an unauthorized access scenario, one could use a module based approach just like the FormsAuthenticationModule. In our case, we instead introduced an AuthorizeWithReturnCookieAttribute, and replaced the use of [Authorize] with [AuthorizeWithReturnCookie].

  1. public class AuthorizeWithReturnCookieAttribute : AuthorizeAttribute
  2. {
  3.     protected override void HandleUnauthorizedRequest(AuthorizationContext filterContext)
  4.     {
  5.         var ctx = filterContext.HttpContext;
  6.         NavigationCookies.SetReturnAfterAuthenticationUrl(ctx, ctx.Request.RawUrl);
  7.         base.HandleUnauthorizedRequest(filterContext);
  8.     }
  9. }

To set the cookie when a relevant page was visisted, we introduced a separate attribute ReturnAfterAuthenticationAttribute. Unlike the authorize attribute, this one is used on public pages, we want the user redirected back to if the login option in the master page/layout is used.

  1. public class ReturnAfterAuthenticationAttribute : ActionFilterAttribute
  2. {
  3.     public const string ViewDataIgnoreKey = "IgnoreReturnAfterAuthentication";
  4.     public override void OnActionExecuted(ActionExecutedContext filterContext)
  5.     {
  6.         var ctx = filterContext.HttpContext;
  7.         if ((bool?)filterContext.Controller.ViewData[ViewDataIgnoreKey] == true) return;
  8.         if (ctx.User.Identity.IsAuthenticated) return;
  9.         if (filterContext.HttpContext.Request.HttpMethod != "GET") return;
  10.         NavigationCookies.SetReturnAfterAuthenticationUrl(ctx, ctx.Request.RawUrl);
  11.     }
  12. }

There are a few more things going on in the above attribute.

  • We can avoid it being set based on logic executed in the action method, by setting true on the corresponding value in ViewData. In retrospective ctx.Items is probably the right place for it.
  • No point in setting the cookie if the user is already authenticated.
  • Post requests are not tracked.

For a more complete sample, the modified logon and register methods of the asp.net mvc template below. It still supports the returnUrl and gives it precendence (not what we did in our case, as we only use the cookie one).

  1. [HttpPost]
  2. public ActionResult LogOn(LogOnModel model, string returnUrl)
  3. {
  4.     if (ModelState.IsValid)
  5.     {
  6.         if (Membership.ValidateUser(model.UserName, model.Password))
  7.             return SetAuthCookieAndRedirect(model.UserName, model.RememberMe, returnUrl);
  8.         ModelState.AddModelError("", "The user name or password provided is incorrect.");
  9.     }
  10.     // If we got this far, something failed, redisplay form
  11.     return View(model);
  12. }
  1. [HttpPost]
  2. public ActionResult Register(RegisterModel model)
  3. {
  4.     if (ModelState.IsValid)
  5.     {
  6.         // Attempt to register the user
  7.         MembershipCreateStatus createStatus;
  8.         Membership.CreateUser(model.UserName, model.Password, model.Email, null, null, true, null, out createStatus);
  9.  
  10.         if (createStatus == MembershipCreateStatus.Success)
  11.             return SetAuthCookieAndRedirect(model.UserName);
  12.         ModelState.AddModelError("", ErrorCodeToString(createStatus));
  13.     }
  14.     // If we got this far, something failed, redisplay form
  15.     return View(model);
  16. }
  1. ActionResult SetAuthCookieAndRedirect(string userName, bool createPersistentCookie=false, string returnUrl=null)
  2. {
  3.     FormsAuthentication.SetAuthCookie(userName, createPersistentCookie);
  4.     var url = IsValidReturnUrl(returnUrl) ?
  5.         returnUrl :
  6.         NavigationCookies.GetReturnAfterAuthenticationUrl(Request);
  7.     if (string.IsNullOrEmpty(url)) return RedirectToAction("Index", "Home");
  8.     return new RedirectResult(url);
  9. }
  10. private bool IsValidReturnUrl(string returnUrl)
  11. {
  12.     return Url.IsLocalUrl(returnUrl) && returnUrl.Length > 1 && returnUrl.StartsWith("/")
  13.            && !returnUrl.StartsWith("//") && !returnUrl.StartsWith("/\\");
  14. }

The NavigationCookies class just sets/gets the cookie:

  1. public class NavigationCookies
  2. {
  3.     private const string ReturnAfterAuthenticationKey = "ReturnAfterAuthentication";
  4.     public static void SetReturnAfterAuthenticationUrl(HttpContextBase context, string url)
  5.     {
  6.         url = VirtualPathUtility.ToAbsolute(url, context.Request.ApplicationPath);
  7.         var cookie = context.Response.Cookies[ReturnAfterAuthenticationKey];
  8.         cookie.Expires = DateTime.Now.AddDays(1);
  9.         cookie.Value = url;
  10.     }
  11.     public static string GetReturnAfterAuthenticationUrl(HttpRequestBase request)
  12.     {
  13.         var cookie = request.Cookies["ReturnAfterAuthentication"];
  14.         if (cookie == null || cookie.Expires > DateTime.Now) return String.Empty;
  15.         return cookie.Value;
  16.     }
  17. }

I added a full sample that uses the asp.net mvc 3 template to the following github repository: https://github.com/eglasius/Cookie-Based-ReturnUrl-After-Authentication-Sample. You can download a zip file of it directly at: https://github.com/eglasius/Cookie-Based-ReturnUrl-After-Authentication-Sample/zipball/master.