This could be a very simple question, but after a few hours trying to understand how this works on ASP.NET 4.0 I still don't know.
I'm using Forms Authentication. I have a login page with a login control on it.
This is what I need when users login:
A- The users should stay logged until the don't do anything for the timeout set. If they reload a page the timeout has to restart the countdown.
B- If they click the "Remember Me" check they should stay connected until they logout, no matter if they close the browser or reboot the computer.
The problem I have is when they login I don't see any cookie on my computer:
Also I have another problem: when they click the "remember me" check (case B) I'd like them logged until they click on the logout button. This time I do see a cookie, but it looks like they stay connected only for the timeout...so what is the difference between the rememeber me or not...
I'd like to separate completely Authentication and Session. I'd like Authentication controlled by cookies if is not very bad approaching.
Thanks for helping-.
Handling Non-Permanent, Sliding Expiration Tickets
Forms Authentication uses an in-memory cookie for the ticket, unless you make it persistent (for example, FormsAuthentication.SetAuthCookie(username, true)
will make it persistent). By default, the ticket uses a sliding expiration. Each time a request is processed, the ticket will be sent down with a new expiration date. Once that date expires, the cookie and the ticket are both invalid and the user will be redirected to the login page.
Forms Authentication has no built-in handling for redirecting pages that have already been rendered, that sit longer than the timeout. You will need to add this yourself. At the simplest level, you will need to start a timer with the document loads, using JavaScript.
<script type="text/javascript">
var redirectTimeout = <%FormsAuthentication.Timeout.TotalMilliseconds%>
var redirectTimeoutHandle = setTimeout(function() { window.location.href = '<%FormsAuthentication.LoginUrl%>'; }, redirectTimeout);
</script>
With the above, if your page is not refreshed or changed, or redirectTimeoutHandle
is not otherwise cancelled (with clearTimeout(redirectTimeoutHandle);
), it will be redirected to the login page. The FormsAuth ticket should have already expired so you shouldn't have to do anything with that.
The trick here is whether or not your site does AJAX work, or you consider other client-side events as active user activity (moving or clicking the mouse, etc). You will have to track those events manually and when they occur, reset the redirectTimeoutHandle
. For example, I have a site that uses AJAX heavily, so the page doesn't physically refresh often. Since I use jQuery, I can have it reset the timeout every time an AJAX request is issued, which should, in effect, result in the page being redirected if they sit on a single page and don't do any updates.
Here's a complete initialization script.
$(function() {
var _redirectTimeout = 30*1000; // thirty minute timeout
var _redirectUrl = '/Accounts/Login'; // login URL
var _redirectHandle = null;
function resetRedirect() {
if (_redirectHandle) clearTimeout(_redirectHandle);
_redirectHandle = setTimeout(function() { window.location.href = _redirectUrl; }, _redirectTimeout);
}
$.ajaxSetup({complete: function() { resetRedirect(); } }); // reset idle redirect when an AJAX request completes
resetRedirect(); // start idle redirect timer initially.
});
By simply sending an AJAX request, the client-side timeout and the ticket (in the form of a cookie) will both be updated, and your user should be fine.
However, if user activity does not cause the FormsAuth ticket to be updated, the user will appear to be logged out the next time they request a new page (either by navigating or via AJAX). In that case, you'll need to "ping" your web application when user activity occurs with an AJAX call to, say, a custom handler, an MVC action, etc. to keep your FormsAuth ticket up to date. Please note that you need to be careful when pinging the server to keep up-to-date, as you don't want to flood the server with requests as they, say, move the cursor around or click on things. Here's an addition to the init script above that adds resetRedirect
to mouse clicks on the document, in addition to the initial page load and AJAX requests.
$(function() {
$(document).on('click', function() {
$.ajax({url: '/ping.ashx', cache: false, type: 'GET' }); // because of the $.ajaxSetup above, this call should result in the FormsAuth ticket being updated, as well as the client redirect handle.
});
});
Handling "Permanent" Tickets
You need the ticket to be sent to the client as a persistent cookie, with an arbitrarily long timeout. You should be able to leave the client code and web.config as they are, but handle the user's preference for a permanent ticket separately in your login logic. Here, you'll need to modify the ticket. Below is logic in a login page to do such a thing:
// assumes we have already successfully authenticated
if (rememberMe)
{
var ticket = new FormsAuthenticationTicket(2, userName, DateTime.Now, DateTime.Now.AddYears(50), true,
string.Empty, FormsAuthentication.FormsCookiePath);
var cookie = new HttpCookie(FormsAuthentication.FormsCookieName, FormsAuthentication.Encrypt(ticket))
{
Domain = FormsAuthentication.CookieDomain,
Expires = DateTime.Now.AddYears(50),
HttpOnly = true,
Secure = FormsAuthentication.RequireSSL,
Path = FormsAuthentication.FormsCookiePath
};
Response.Cookies.Add(cookie);
Response.Redirect(FormsAuthentication.GetRedirectUrl(userName, true));
}
else
{
FormsAuthentication.RedirectFromLoginPage(userName, false);
}
Bonus: Storing Roles in Ticket
You asked if you can store roles in the ticket/cookie so you don't have to look them up again. Yes, that is possible, but there are some considerations.
To elaborate on #2:
You shouldn't implicitly trust claims that you receive from a user. For example, if a user logs in and is an Admin, and checks "remember me" thus receiving a persistent, long-term ticket, they will be an Admin forever (or until that cookie expires or is erased). If someone removes them from that role in your database, the application will still think they are an Admin if they have the old ticket. So, you may be better off getting the user's roles every time, but caching the roles in the application instance for a period of time to minimize database work.
Technically, this is also an issue for the ticket itself. Again, you shouldn't trust that just because they have a valid ticket that the account is still valid. You can use similar logic as the roles: Check that the user referenced by the ticket still exists and is valid (that it's not locked out, disabled, or deleted) by querying your actual database, and just caching the db results for a period of time to improve performance. This is what I do in my applications, where the ticket is treated as an identity claim (similarly, username/password is another type of claim). Here is simplified logic in the global.asax.cs (or in an HTTP module):
protected void Application_AuthenticateRequest(Object sender, EventArgs e)
{
var application = (HttpApplication)sender;
var context = application.Context;
EnsureContextUser(context);
}
private void EnsureContextUser(HttpContext context)
{
var unauthorizedUser = new GenericPrincipal(new GenericIdentity(string.Empty, string.Empty), new string[0]);
var user = context.User;
if (user != null && user.Identity.IsAuthenticated && user.Identity is FormsIdentity)
{
var ticket = ((FormsIdentity)user.Identity).Ticket;
context.User = IsUserStillActive(context, ticket.Name) ? new GenericPrincipal(user.Identity, GetRolesForUser(context, ticket.Name)) : unauthorizedUser;
return;
}
context.User = unauthorizedUser;
}
private bool IsUserStillActive(HttpContext context, string username)
{
var cacheKey = "IsActiveFor" + username;
var isActive = context.Cache[cacheKey] as bool?
if (!isActive.HasValue)
{
// TODO: look up account status from database
// isActive = ???
context.Cache[cacheKey] = isActive;
}
return isActive.GetValueOrDefault();
}
private string[] GetRolesForUser(HttpContext context, string username)
{
var cacheKey = "RolesFor" + username;
var roles = context.Cache[cacheKey] as string[];
if (roles == null)
{
// TODO: lookup roles from database
// roles = ???
context.Cache[cacheKey] = roles;
}
return roles;
}
Of course, you may decide you don't care about any of that and just want to trust the ticket, and store the roles in the ticket as well. First, we update your login logic from above:
// assumes we have already successfully authenticated
if (rememberMe)
{
var ticket = new FormsAuthenticationTicket(2, userName, DateTime.Now, DateTime.Now.AddYears(50), true, GetUserRolesString(), FormsAuthentication.FormsCookiePath);
var cookie = new HttpCookie(FormsAuthentication.FormsCookieName, FormsAuthentication.Encrypt(ticket))
{
Domain = FormsAuthentication.CookieDomain,
Expires = DateTime.Now.AddYears(50),
HttpOnly = true,
Secure = FormsAuthentication.RequireSSL,
Path = FormsAuthentication.FormsCookiePath
};
Response.Cookies.Add(cookie);
Response.Redirect(FormsAuthentication.GetRedirectUrl(userName, true));
}
else
{
var ticket = new FormsAuthenticationTicket(2, userName, DateTime.Now, DateTime.Now.AddMinutes(FormsAuthentication.Timeout), false, GetUserRolesString(), FormsAuthentication.FormsCookieName);
var cookie = new HttpCookie(FormsAuthentication.FormsCookieName, FormsAuthentication.Encrypt(ticket))
{
Domain = FormsAuthentication.CookieDomain,
HttpOnly = true,
Secure = FormsAuthentication.RequireSSL,
Path = FormsAuthentication.FormsCookiePath
};
Response.Cookies.Add(cookie);
Response.Redirect(FormsAuthentication.GetRedirectUrl(userName, false));
}
Add method:
private string GetUserRolesString(string userName)
{
// TODO: get roles from db and concatenate into string
}
Update your global.asax.cs to get roles out of ticket and update HttpContext.User:
protected void Application_AuthenticateRequest(Object sender, EventArgs e)
{
var application = (HttpApplication)sender;
var context = application.Context;
if (context.User != null && context.User.Identity.IsAuthenticated && context.User.Identity is FormsIdentity)
{
var roles = ((FormsIdentity)context.User.Identity).Ticket.Data.Split(",");
context.User = new GenericPrincipal(context.User.Identity, roles);
}
}