Ensuring that an MVC application is fully secured can feel daunting. At first glance, the most important part of the login process is the login screen itself, and yes this is the natural starting point, but it's only the beginning.
Login Screen
In our case, we're making use of the standard login fields (username, password & login button) which we've expanded upon by providing a SSO sign-in options supporting both Azure and local Active Directory integration.
When a user attempts to login, we validate the credentials within our product database and return basic status information, perhaps a success code or an error message. This an area where want our error messages to remain ambiguous; we can retain an element of security by not differentiating between users who don't exist or where a login has been attempted with an incorrect password. This simple approach makes it harder to ascertain if an incorrect username has been entered and makes it twice as hard for any hacking attempt to be successful.
We can take this a step further & prevent brute force hacking attempts by temporarily blocking users or perhaps IP addresses where a number of incorrect logins have been attempted within a short time period.
JWT Configuration
A big problem is that once a user has logged in, we'll still need to verify their access rights each time an operation is performed. In a modern environment where a single page can make multiple AJAX calls this can amount to a high number of repeated database calls or a lot of cached user data.
While it initially sounds unavoidable, we can drastically improve product performance by keeping those database calls down.
This is where JWT comes in.
Each instance of our application is provided with a random Client Secret. I configured ours so that it's automatically inserted into web.config at the point of installation.
This unique value is then used to provide the user with two tokens:
Access Token
The access token is a short-lived token typically living for 5-15 minutes. It contains commonly accessed user information, perhaps a username, configuration options and security access rights.
This information is encoded into an alphanumeric string and can optionally be encrypted.
// using Microsoft.IdentityModel.Tokens;
// using System.IdentityModel.Tokens.Jwt;
/*
Get the client secret.
Our usage of the client secret ensures that the request
and access tokens are generated using completely unique
values.
In this example we're using a hard-coded constant, but
this could be achieved any number of ways.
*/
var key = Encoding.ASCII.GetBytes(_jwtConfig.Secret +
ACCESS_TOKEN_SECRET_SUFFIX);
var tokenHandler = new JwtSecurityTokenHandler();
var claims = new Dictionary<string, object>
{
// Add user configuration and security details here
};
var tokenDescriptor = new SecurityTokenDescriptor
{
Subject = new ClaimsIdentity(new[] {
new Claim("id", activeUser.Id.ToString()),
new Claim("userName", activeUser.UserName.ToString())
}),
IssuedAt = DateTime.UtcNow,
Claims = claims,
CompressionAlgorithm = CompressionAlgorithms.Deflate,
Expires = DateTime.UtcNow.AddMinutes(_jwtConfig.AccessTokenValidMinutes),
SigningCredentials = new SigningCredentials(
new SymmetricSecurityKey(key),
SecurityAlgorithms.HmacSha256Signature,
SecurityAlgorithms.HmacSha256Signature
)
};
var token = tokenHandler.CreateToken(tokenDescriptor);
return tokenHandler.WriteToken(token);
Refresh Token
The refresh token has a longer life, perhaps 4 hours. It won't contain any sensitive information beyond a user identifier and even this can be rendered unnecessary.
The purpose of this token is simply to preserve a user login outside of an webserver session and to enable regeneration of the access token without the need to store usernames and passwords within cookies.
// using Microsoft.IdentityModel.Tokens;
// using System.IdentityModel.Tokens.Jwt;
var tokenHandler = new JwtSecurityTokenHandler();
expiryDate = _jwtConfig.RefreshTokenValidMinutes;
var key = Encoding.ASCII.GetBytes(_jwtConfig.Secret +
REFRESH_TOKEN_SECRET_SUFFIX);
var claims = new Dictionary<string, object>
{
{ "expiry", expiryDate },
/*
We provide several login methods which aren't user
specific, this will be lost when the access token
expires, so we'll save it to the refresh token.
*/
{ "loginMode", activeUser.LoginStatus },
/*
We also require a separate user CRM login so this
information also gets encoded in our refresh token.
*/
{ "crmRoles", activeUser.ConnectedCrmFunctionality },
{ "isAuthenticatedToCrm", activeUser.IsAuthenticatedToCrm },
};
var tokenDescriptor = new SecurityTokenDescriptor
{
Subject = new ClaimsIdentity(new[] {
new Claim("id", activeUser.Id.ToString()),
new Claim("userName", activeUser.UserName.ToString())
}),
IssuedAt = DateTime.UtcNow,
Claims = claims,
CompressionAlgorithm = CompressionAlgorithms.Deflate,
Expires = expiryDate,
SigningCredentials = new SigningCredentials(
new SymmetricSecurityKey(key),
SecurityAlgorithms.HmacSha256Signature,
SecurityAlgorithms.HmacSha256Signature)
};
var token = tokenHandler.CreateToken(tokenDescriptor);
return tokenHandler.WriteToken(token);
Login
Once a users' credentials have been confirmed the JWT access and refresh tokens are returned to the client as cookies.
These cookies should be set to expire at the same time as the associated JWT token, configured with HttpOnly
enabled and the SameSite
mode set to Lax
. This will secure the cookies so that they can't be read by JavaScript and can only be included in requests sent to the originating server.
One thing of note is that cookie sizes are typically restricted to 4Kb so we need to be careful about what information is included within the access token, keeping it to critical & commonly used values only and ensuring that the encoding method in place removes unnecessary text (i.e. don't encode values using JSON with long property names).
Depending on application flow, it may be necessary to add cookies not only to the response object but also to the request object. This will permit the access token to be regenerated at the start of a call and for the new value to persist without the need to make an additional server-side call.
var cookie = new HttpCookie(cookieName, value)
{
Expires = DateTime.Now.AddMinutes(expiry),
HttpOnly = httpOnly,
SameSite = SameSiteMode.Lax
};
if (!context.Response.Cookies.AllKeys.Contains(cookieName))
{
context.Response.Cookies.Add(cookie);
}
else
{
/* If cookie already exists just update it otherwise we'll add
more data to the HTTP response and potentially trigger an
overflow */
context.Response.Cookies.Set(cookie);
}
Securing Server-Side Functions
Now that the JWT tokens are available as cookies we can secure our server-side functions. We achieve this by creating an instance of AuthorizeAttribute
which in this example we'll call ProductAuthorizeAttribute
.
First we need to reference this attribute in FilterConfig.cs
, this will make sure that all functions are secured by default.
public class FilterConfig
{
public static void RegisterGlobalFilters(GlobalFilterCollection filters)
{
/*
Set order to a value greater than 0 so that
we can override default security on a
per-function basis.
*/
filters.Add(new ProductSecurityAttribute(), 255);
}
}
We'll want to prevent security from being triggered on certain operations, such as the login screen. This can be done by adding the AllowAnonymous
attribute to any functions that don't need to be secured.
public class LoginController : BaseAsyncController
{
[AllowAnonymous]
public ActionResult Index()
{
If we want to apply additional security on a given function we can do this by applying the ProductSecurityAttribute
to it and specifying any additional properties.
[ProductSecurityAttribute(Operation = SecurityType.Create)]
public async Task<ActionResult> ActionName()
{
We can then setup the security attribute so that it automatically reads and decodes the JWT tokens and returns the user to a login or access denied screen if appropriate.
public class ProductSecurityAttribute : AuthorizeAttribute
{
private bool _triggerTimeout;
public ProductSecurityAttribute()
{
/*
We need to set the attribute order to 0 so that
it gets processed before the default attribute
configured in FilterConfig.cs.
*/
Order = 0;
}
// Extra security properties here
/*
Now we override the authorise command and
add our own logic
*/
protected override bool AuthorizeCore(HttpContextBase httpContext)
{
var hasAccess = false;
// decode cookies
var accessCookie = _cookieHelper.Get(_currentContext, ACCESS_TOKEN_NAME);
var refreshCookie = _cookieHelper.Get(_currentContext, REFRESH_TOKEN_NAME);
// decode access token
var jwtUtils = new JwtUtils(_jwtConfig, HttpContext.Current, _cookieHelper);
var accessToken = jwtUtils.VerifyAccessToken(accessTokenValue);
/*
If the access token has expired then generate a
new one using the refresh token.
*/
if (accessToken.LoginStatus == UserLoginStatus.ExpiredAccessToken ||
accessToken.LoginStatus == UserLoginStatus.TokenMissing)
{
var refreshToken = jwtUtils.VerifyRefreshToken(refreshTokenValue);
/*
If the access token has expired but the refresh
token is fine then regenerate both tokens.
*/
if (refreshToken.Id > 0 &&
refreshToken.LoginStatus != UserLoginStatus.ExpiredAccessToken &&
refreshToken.LoginStatus != UserLoginStatus.ExpiredRefreshToken &&
refreshToken.LoginStatus != UserLoginStatus.Failure)
{
// regenerate access & refresh tokens
}
}
/*
If all tokens have expired, return false and set
_triggerTimeout = true.
Otherwise, execute custom security code to work out if
the user has the necessary access
*/
if (hasAccess)
{
// user has access
return true;
}
else
{
// redirect to access denied screen
SetAccessDeniedModel(httpContext, itemIdValue);
return false;
}
}
/*
When authentication fails SecurityAttribute automatically
calls this function which we can override to define
how the application behaves when login fails.
*/
protected override void HandleUnauthorizedRequest(AuthorizationContext filterContext)
{
// child actions can't execute a redirect
var isChildAction = filterContext.IsChildAction ||
filterContext.HttpContext.Request.IsAjaxRequest();
if (_triggerTimeout)
{
// The user login has timed out.
var redirectParams = new StringBuilder();
redirectParams.Append("?logout=true");
if (HttpContext.Current.Request.ApplicationPath !=
HttpContext.Current.Request.CurrentExecutionFilePath.TrimEnd('/'))
{
redirectParams.Append($"&missingSession&targetPage={HttpContext.Current.Request.CurrentExecutionFilePath}");
}
/*
If this is a child action then return an
error state otherwise we can redirect the
user to the login screen.
If we throw 401 in an environment using IIS
windows authentication then the browser will
prompt the user for windows credentials so we're
using a 405 instead.
*/
var redirectPath = $"~/Login{redirectParams}";
filterContext.Result = isChildAction
? (ActionResult)new HttpStatusCodeResult(System.Net.HttpStatusCode.MethodNotAllowed, redirectPath)
: new RedirectResult(redirectPath);
}
else
{
// The user is logged in but access has been denied.
var redirectPath = "~/AccessDenied?redirect=true";
filterContext.Result = isChildAction
? (ActionResult)new HttpStatusCodeResult(System.Net.HttpStatusCode.Forbidden, redirectPath)
: new RedirectResult(redirectPath); // redirects the particular response, not everything
}
}
}
Securing AJAX Calls
SecurityAttribute is able to redirect calls to an access denied or login screen, but we can't do that as easily when an AJAX call fails and if a page is AJAX heavy then we'll need to process both response types.
$.ajaxSetup({
// Prevent ajax calls from caching the response
cache: false,
headers: antiForgeryTokenHeader(),
// Intercept the AJAX complete function
complete: function (xhr) {
if (xhr.status == 405) {
/*
User session has timed out.
Redirect user to the login screen,
encode the target page in the URL
so we can redirect on login
*/
var rootVal = "@(Url.Content("~"))";
var currentHref = window.location.href;
var targetPage = currentHref.substring(currentHref.indexOf(rootVal));
var targetPageParam = "";
if (targetPage != rootVal) {
targetPageParam = "&targetPage=" + targetPage;
}
window.location.href = "@(Url.Content("~/Login"))?logout=true&missingSession" + targetPageParam;
setLocalStorageUserState("logout");
}
else if (xhr.status == 403) {
// access denied message
window.location.href = "@(Url.Content("~/AccessDenied?redirect=true"))";
}
// ignore signalr notifications for service status etc
else if (xhr.responseText != '{ "Response": "pong" }')
{
// process user activity timeout
userTimeoutReset();
}
}
});
Making Token Values Available to the Browser
The standard approach should always be to create the JWT cookie using the HttpOnly
parameter because this will ensure that an attacker can't leverage JavaScript to read the access and/or request tokens.
However there are scenarios where it might be necessary to read values included within the access token from within the browser.
In this scenario it's useful to know that a JWT token is split into Header, Payload and Signature. We can split these into separate cookies and allow payload data to be read from within JavaScript but re-combine them within server operations in order to validate the token.
Forcing Session Timeout
We can use this method (or another cookie) to expose the amount of time until the users session is due to expire and display a warning message on-screen before automatically logging the user out.
function userTimeoutReset() {
//reset any active timeout
userTimeoutElapsed(false);
var timeoutDate = get_cookie("t");
if (timeoutDate != null && timeoutDate.length > 0) {
/*
Calculate ticks until we should start
displaying the timeout warning
with 2 minutes leeway
*/
var timeoutTicks =
new Date(timeoutDate) -
new Date() -
((USER_SECURITY_DEFAULT_TIMEOUT_PERIOD_MINUTES + 2) * 60000);
userTimeout = setTimeout(function() {
userTimeoutElapsed(true);
}, timeoutTicks);
}
}
function userTimeoutElapsed(timeoutElapsed)
{
if (userTimeout != null)
{
clearTimeout(userTimeout);
}
if (userInterval != null) {
clearInterval(userInterval)
}
if (timeoutElapsed) {
userTimeoutCountdownPeriod = USER_SECURITY_DEFAULT_TIMEOUT_PERIOD_MINUTES * 60;
setLocalStorageUserState("resetTimeoutCounter");
userInterval = window.setInterval(function () {
if (userTimeoutCountdownPeriod > 0) {
userTimeoutCountdownPeriod = userTimeoutCountdownPeriod - 1;
}
$("#userTimeout").html("Inactivity timeout in " + userTimeoutCountdownPeriod + " seconds");
if (userTimeoutCountdownPeriod == 0) {
window.location.href = ROOT + "Login?logout=true&timeout=true&targetPage=" + window.location.pathname + window.location.search;
setLocalStorageUserState("logout");
}
}, 1000);
}
else {
setLocalStorageUserState("abortTimeout");
}
}
Persisting Logout to all Tabs
Finally, if the user has multiple tabs open then we'll need to maintain logout timer between all tabs to avoid the user being automatically logged out on a tab that's been ignored for a few hours.
We can use browser local storage to send messages between tabs.
function setLocalStorageUserState(value) {
if (window.localStorage != null) {
/*
Reset the existing value
The change event won't trigger if the
value is the same so we'll need to
do it twice.
*/
window.localStorage.setItem("UserState", "");
window.localStorage.setItem("UserState", value);
}
}
/*
Listen for events from other tabs,
process result in a manner which
will prevent getting into an
endless loop of tab communication
*/
window.addEventListener('storage', (event) => {
if (event.storageArea != localStorage)
return;
/*
We're processing a message from another
tab, prevent this tab from sending messages
to other tabs & sending us into a loop
*/
if (event.key == "UserState" && event.newValue != "") {
// if the user has an active session
if (window.location.href.indexOf("Login?logout") == -1) {
if (event.newValue === 'logout') {
// another page has been redirected to the login screen
window.location.href = ROOT + "Login?logout=true&targetPage=" + window.location.pathname + window.location.search;
}
else if (event.newValue === 'abortTimeout') {
// action was performed on another tab while we were counting down to timeout, abort this
userTimeoutElapsed(false);
}
else if (event.newValue === 'resetTimeoutCounter') {
// action was performed on another tab, reset the timeout counter
userTimeoutElapsed(true);
}
}
}
});
Top comments (0)