DEV Community

Michael Kennedy
Michael Kennedy

Posted on

Securing User Logins with MVC and JWT

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);
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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()
    {
Enter fullscreen mode Exit fullscreen mode

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()
{
Enter fullscreen mode Exit fullscreen mode

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
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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();
        }
    }
});
Enter fullscreen mode Exit fullscreen mode

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");
    }
}
Enter fullscreen mode Exit fullscreen mode

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);
            }
        }
    }
}); 
Enter fullscreen mode Exit fullscreen mode

Oldest comments (0)