Default Behavior and the Scenario
On ASP.NET in general, when a 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 logs in.
When a 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 success, 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 forgotten 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]
.
public class AuthorizeWithReturnCookieAttribute : AuthorizeAttribute
{
protected override void HandleUnauthorizedRequest(AuthorizationContext filterContext)
{
var ctx = filterContext.HttpContext;
NavigationCookies.SetReturnAfterAuthenticationUrl(ctx, ctx.Request.RawUrl);
base.HandleUnauthorizedRequest(filterContext);
}
To set the cookie when a relevant page was visited, 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.
public class ReturnAfterAuthenticationAttribute : ActionFilterAttribute
{
public const string ViewDataIgnoreKey = "IgnoreReturnAfterAuthentication";
public override void OnActionExecuted(ActionExecutedContext filterContext)
{
var ctx = filterContext.HttpContext;
if ((bool?)filterContext.Controller.ViewData[ViewDataIgnoreKey] == true) return;
if (ctx.User.Identity.IsAuthenticated) return;
if (filterContext.HttpContext.Request.HttpMethod != "GET") return;
NavigationCookies.SetReturnAfterAuthenticationUrl(ctx, ctx.Request.RawUrl);
}
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 retrospect, 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 precedence (not what we did in our case, as we only use the cookie one).
[HttpPost]
public ActionResult LogOn(LogOnModel model, string returnUrl)
{
if (ModelState.IsValid)
{
if (Membership.ValidateUser(model.UserName, model.Password))
return SetAuthCookieAndRedirect(model.UserName, model.RememberMe, returnUrl);
ModelState.AddModelError("", "The user name or password provided is incorrect.");
}
return View(model);
}
[HttpPost]
public ActionResult Register(RegisterModel model)
{
if (ModelState.IsValid)
{
MembershipCreateStatus createStatus;
Membership.CreateUser(model.UserName, model.Password,
model.Email, null, null, true, null, out createStatus);
if (createStatus == MembershipCreateStatus.Success)
return SetAuthCookieAndRedirect(model.UserName);
ModelState.AddModelError("", ErrorCodeToString(createStatus));
}
return View(model);
}
ActionResult SetAuthCookieAndRedirect(string userName,
bool createPersistentCookie=false, string returnUrl=null)
{
FormsAuthentication.SetAuthCookie(userName, createPersistentCookie);
var url = IsValidReturnUrl(returnUrl) ?
returnUrl :
NavigationCookies.GetReturnAfterAuthenticationUrl(Request);
if (string.IsNullOrEmpty(url))
return RedirectToAction("Index", "Home");
return new RedirectResult(url);
}
private bool IsValidReturnUrl(string returnUrl)
{
return Url.IsLocalUrl(returnUrl) && returnUrl.Length >
1 && returnUrl.StartsWith("/")
&& !returnUrl.StartsWith("//")
&& !returnUrl.StartsWith("/\\");
The NavigationCookies
class just sets/gets the cookie:
public class NavigationCookies
{
private const string ReturnAfterAuthenticationKey = "ReturnAfterAuthentication";
public static void SetReturnAfterAuthenticationUrl(HttpContextBase context, string url)
{
url = VirtualPathUtility.ToAbsolute(url, context.Request.ApplicationPath);
var cookie = context.Response.Cookies[ReturnAfterAuthenticationKey];
cookie.Expires = DateTime.Now.AddDays(1);
cookie.Value = url;
}
public static string GetReturnAfterAuthenticationUrl(HttpRequestBase request)
{
var cookie = request.Cookies["ReturnAfterAuthentication"];
if (cookie == null || cookie.Expires > DateTime.Now) return String.Empty;
return cookie.Value;
}
I added a full sample that uses the ASP.NET MVC 3 template to the following github repository:
You can download a zip file of it directly from here.