Monday, April 7, 2014

User Authentication/Life-Cycle Management Best Practices

The last couple of years, we have witnessed an ever increasing number of pwnage to various sites, leading in compromised user accounts. A lot of these sites do not even have the user credentials hashed (even using the least amount of effort) but rather have the username/password pairs as plaintext. You can go to haveibeenpwned.com for a small list of such compromises. And the ones mentioned are mostly fairly recent to the blogs posting.

This allows malicious users that gain access to this data to possibly use the same credentials on different sites (because nobody reuses passwords, right?).

A greater concern is also the fact that the compromised site/services do not disclose the issue in a timely manner (in a lot of cases they are unaware the compromise happened!!!), so the user is unaware that their credentials have been leaked, allowing them (if we are talking about a slightly above average internet user) to do anything possible to protect themselves. Of course, little can be done if the site/service has very poor security mechanisms (if the user changes their password, the hacker could simply re-dump the password database for example). But, this is a conversation for another time.

What I will be focusing in this blog post is how to correctly manage user accounts in a web application. There is an abundance of frameworks out there that provide all the piping necessary to help a developer correctly set up the whole identity life-cycle. Some have more features than others, but at least they provide a start point for a developer to create more secure code. Why on earth a developer would try to re-invent the wheel is beyond me - after all, one of the basic principles of programming is code re-usability.

The blog post was heavily influenced by daily news, and Episode 1 of the Professionally Evil Perspective podcast which mentions some of the below points I will be making.

For the purposes of the blog post, I have created a Web Site project using Visual Studio 2010, which is available on codeplex.com here. I leverage the ASP.Net Application Services framework for the piping, but the same concepts hold for most of the other authentication and user management frameworks available out there.


Step 1: Secure Login

 

Even the most crude and simple implementations need to have this implemented; logins should always use TLS (even better, the whole site should be under TLS - overhead concerns are antiquated nowadays). For an extremely good explanation of the concerns and consequences of this, you can go to Troy Hunts blog here.

Again folks, this is a must. If you are not willing to at least secure the login process, don't even bother reading the rest of the blog post. All the recommendations I will be making below assume that at least the account life-cycle pages are correctly secured with TLS.

Step 2: Correct Account Creation


First the bad practices

The first point I would like to make here is what to use for a username. I come across a lot of web sites that require me to create an account, and they ask me for two things: username and email address. And they don't need to match. Why? Again, why?

There is no purpose in having two identifying fields for a user (other than filling up your back end database faster). No purpose whatsoever. It has a serious implication though: when you ask for a generic username, you need to check if that username is already taken by another user. Result? Information disclosure. The user requesting the account instantly knows that there is already a user with that account using the site/service. A malicious user will leverage this information to try and brute force their login.

Then, say they don't ask you for a username but only for an email/password pair. While better, there is a small caveat here; if the account is automatically created and the user gets automatically logged on and can use the site/service without further action, this is problematic. The issue is that in this scenario, any user can use any email (even one they don't control) and create/use an account on the site/service. This is a Denial of Service type of issue (not a major one, but when the legitimate owner of the email account tries to create an account for themselves, they can't - they will need to provide another email address).

So, what's the purpose of asking the user's email if you won't at least go to the trouble of verifying the email ownership (apart from sending the user spam/marketing material to make $$$).

Another point I would like to make is the secret question/answer model. It should not in my honest opinion be used for password resets. Why? Most sites give the user a list of secret question options to choose from (I won't even consider the case where you are not even given a choice of question). Most of the questions request very basic information (such as "What is you mother's maiden name"). Well, if my mother is on Facebook, her profile is public and she lists her maiden name, the security afforded by the mechanism just went out the window (and that is never the case, right?). Also, most people will never, ever consider entering as answers arbitrary values (for example "What was your first pet's name" could be answered with "I love hotdogs") but most people will actually truthfully answer the question, since it is easier to remember (and again, because we never post this kind of information on social networks, this is a good practive, right?).

Now the good practices

One piece of information is needed. Just one. The user's email address. How is this secure? It all depends on the correct implementation.

The basic workflow would look something like this:
  1. Ask the user trying to register for their email address.
  2. Once given, redirect to a page informing them that a time-sensitive one-time link has been sent to the given email address. They will need to click it to continue with the account creation process. The page also sets a cookie with an encrypted value that expires in a given (short) period of time.
  3. User clicks the link and is presented with a page asking for a password.
  4. Account is created.

That's it.

The security of the above comes from the following features of the solution:
  1. The process is done over TLS, so no man-in-the-middle that could change the email address on the wire.
  2. Only the user that has access to the email account can retrieve the sent email (this is not completely true, since email is sent unencrypted, and it relies on the user having sole control on their email - it was not also hacked - but we can't secure everything now, can we?).
  3. The link is time-sensitive. This means that once generated, the user has a short window to click on it and create the account (say 5 minutes. If the user really wants access, they won't wait for more than that to create their account). The benefit of this is that if an email address was given from a malicious user that does not have access to that email address, the link will expire allowing a user with the same email address (possibly the legitimate one) to create an account.
  4. The link is a challenge. It holds an encrypted query string value (and HTML encoded) that once passed to the linked page, it is decrypted (using the long secret key that encrypted it) provides the key that when used to encrypt another long secret value, results in the encrypted value set in the cookie.
Just to elaborate a little more on the activation procedure. Using (correctly) encrypted values, we ensure that an attacker cannot recreate the values on their own (or decrypt them). Also, this allows us, instead of creating a mechanism to store the activation codes (and delete expired ones), we use cookies. The caveat of this is that a user could request the account from one device and complete the registration on another, but that is unlikely (we could include a message on the page informing the user that they need to complete registration in X minutes on the same device).

I removed the CreateUserWizard control added by default from the Web Site template, since I don't want the account to actually be created until the user completes the registration. So, I replaced that with a simple text box where the user will add their email and a button to submit the request. Here is the code-behind for the button event:

        // Create two Guids
        Guid emailGuid = Guid.NewGuid();
        Guid cookieGuid = Guid.NewGuid();

        // Use the emailGuid to encrypt the cookie guid
        String encryptedCookieGuid = EncyptionHelper.EncryptStringWithKey(cookieGuid.ToString(), emailGuid.ToString());

        // Set the encrypted emailGuid in a cookie
        HttpCookie cookie = new HttpCookie("ChallengeCookie")
        {
            Value = encryptedCookieGuid,
            Expires = DateTime.Now.AddMinutes(10)
        };
        Response.Cookies.Add(cookie);

        // Encrypt the emailGuid (concatenate the email address to that)
        String encryptedEmailGuid = EncyptionHelper.EncryptString(emailGuid.ToString() + Email.Text);

        // Create the email body
        StringBuilder sb = new StringBuilder();
        sb.AppendLine("A request for creating a user in our system has been received.");
        sb.AppendLine("");
        sb.AppendLine("To complete the request and create your user account, please click on the following link:");
        sb.AppendLine("");
        sb.AppendLine(String.Format("Verify Account", 
            HttpContext.Current.Request.Url.Scheme, 
            "localhost:51032/AccountPoliciesWebSite", 
            encryptedEmailGuid));
        sb.AppendLine("");
        sb.AppendLine(String.Format("Please note that the link will expire on {0}", DateTime.Now.AddMinutes(10).ToString()));
        sb.AppendLine("");
        sb.AppendLine("If you have received this email without trying to create an account on our site, please ignore it.");

        // Send the email
        EmailHelper.send(Email.Text, "Registration Request", sb.ToString());

        Response.Redirect("EmailSent.aspx");

and here is the code when the user clicks the registration link sent by email:

        // Hide both status labels
        lblVerified.Visible = false;
        lblCannotVerify.Visible = false;

        // Check if the cookie is set (not expired) and if the querystring is present
        if (Request.Cookies["ChallengeCookie"] == null || Request.QueryString["val"] == null)
        {
            lblCannotVerify.Visible = true;
        }
        else
        {
            try
            {
                // Get the cookie value
                String challenge = Request.Cookies["ChallengeCookie"].Value;

                // Get the query string value
                String encryptedEmailGuid = Request.QueryString["val"].ToString();

                // Decrypt the query string to get the email Guid and email address
                String emailGuidWithEmailAddress = EncyptionHelper.DecryptString(encryptedEmailGuid);

                // Email Guid and address
                String emailGuid = emailGuidWithEmailAddress.Substring(0, 36);
                String email = emailGuidWithEmailAddress.Substring(36, emailGuidWithEmailAddress.Length - 36);

                // Decrypt the cookie Guid
                String cookieGuid = EncyptionHelper.DecryptStringWithKey(challenge, emailGuid);

                Guid toParse = Guid.Empty;

                // Check if it is a valid guid
                if (!Guid.TryParse(cookieGuid, out toParse))
                {
                    lblCannotVerify.Visible = true;
                }
                else
                {
                    // If the user exists
                    if (Membership.GetUserNameByEmail(email) != null)
                        lblCannotVerify.Visible = true;
                    else
                    {
                        // Allowed to add user
                        lblVerified.Visible = true;
                        pnlAdditonalInfo.Visible = true;
                    }
                }
            }
            catch
            {
                lblCannotVerify.Visible = true;
            }
        }

If the cookie and QueryString values are valid, the user will be prompted to set a password (in an actual production implementation, you could request more details such as name, address etc). There is a button then which the user presses to create their account. Here is the code associated with the button:

        ErrorMessage.Visible = false;

        // Get the user's email address
        String encryptedEmailGuid = Request.QueryString["val"].ToString();

        String emailGuidWithEmailAddress = EncyptionHelper.DecryptString(encryptedEmailGuid);

        String emailGuid = emailGuidWithEmailAddress.Substring(0, 36);
        String email = emailGuidWithEmailAddress.Substring(36, emailGuidWithEmailAddress.Length - 36);

        try
        {
            // Create the user
            Membership.CreateUser(email, Password.Text, email);

            // Expire the cookie value to invalidate the link
            HttpCookie cookie = new HttpCookie("ChallengeCookie")
            {
                Expires = DateTime.Now.AddDays(-1)
            };

            Response.Cookies.Add(cookie);

            // Log-in the user
            FormsAuthentication.SetAuthCookie(email, false);

            Response.Redirect("AccountCreatedSuccessfully.aspx");
        }
        catch (MembershipCreateUserException ex)
        {
            // We should only get invalid password error
            // But, it could also be a provider error
            switch (ex.StatusCode)
            {
                case MembershipCreateStatus.InvalidPassword:
                    ErrorMessage.Text = String.Format("Passwod must be at least {0} characters long and contain at least {1} special characters.", Membership.MinRequiredPasswordLength, Membership.MinRequiredNonAlphanumericCharacters);
                    break;
                case MembershipCreateStatus.ProviderError:
                    ErrorMessage.Text = String.Format("Could not create account. Please try registering again later.");
                    break;
            }

            ErrorMessage.Visible = true;
        }

Additionally, the account creation page (workflow step 3) could ask the user to provide their own security question and security answer. This is optional but it is useful later on when we will be discussing password reset best practices.

Step 3: Secure storage of account details


First the bad practices


Remember the compromised sites I mentioned in the beginning of the post? They all used non-secure storage of accounts of security was sub-par. Usernames and passwords must not be stored in plain text. If the account database is compromised, can you calculate the time a hacker needs to harvest user credentials? Zero if they are in plaintext, trivial if they are simply hashed without salt.

Also, don't mix the application database with the account database. Even if you correctly set up account creation, hash and salt the stored passwords, do everything right, a simple SQL injection flaw in your application will allow a hacker to execute commands on you application database. Where your account information is stored. See where this is going? At best, the hacker will harvest the usernames. At worst, they will delete all the account records. Instant DoS.

Now the good practices


First in the list is store the user account information securely. This means each user's password needs to be stored using a secure salted hash such as bcrypt, PBKDF2 or scrypt, with multiple rounds (non of that MD4 crap - excuse my language - that is trivially brute forced nowadays - especially the unsalted kind), and each salt value must be unique to each user. This will thwart any attempts of brute forcing. Now if the account database is compromised, can you calculate the time a hacker needs to harvest one password? The ballpark figure is larger than the life of the universe (can't find the reference - you will need to take my word on this).

Second in the list is store the user account information in a separate database. This will protect the user account database in case there are SQL injection flaws in the application. Of course, the two databases must be segmented and correctly protected by stronger mechanisms, which is out of the scope of this blog post.

Microsoft's ASP.Net Application Services Membership Provider uses a separate database that can be created using the aspnet_regsql.exe command. User credentials are stored using a salted hash (by default SHA1 but can be changed by defining the hashAlgorithmType attribute in the membership section of your web.config) with unique salt values per identity. And all of this is out-of-the-box. No coding (especially custom encryption code :) ) needed.

Step 4: Account Lockout Policy


Now this is a sensitive matter. There are a lot of proponents to not locking out a user account, and a lot of proponents that say account lockouts are needed.

First of all, lets figure out what could go wrong without account lockouts. If a malicious user figures out that a specific user is registered in the site/service, they can attempt to brute force force their password by simply trying all possible passwords. Of course, the time needed to do so is exponential to the length of the password and the available character space, but most high-value targets of malicious users are not so security sensitive; take for example a Hollywood actor/actress. They are more than likely to use an easy to remember (and easily guessable) password, most likely short. So, in this case, locking out the account would make sense.

But what happens when the account is locked? If a malicious user constantly tries to brute force the passwords of one or more individuals in a site/service, even if the accounts automatically get unlocked, they would almost instantly get locked again, resulting in effect in a DoS scenario. Thinking of this in this light, accounts should not get locked out.

There is also the concept of what is the user shown in case their account is locked out. Do they get the generic error given for incorrect credentials or are they presented with a message that their account was locked out? In the former, the user is unable to determine that their account is locked out. In the later, we have information leakage, since such a message would inform an attacker that the specific user account actually exist.

So, is there a middle ground? I believe there is. Consider the following workflows:

Failed login attempt:
  1. The user tries to log on with a wrong password.
  2. Log the request's IP address.
  3. Check the lockout state of the account.
  4. If the account is locked out, pause for a set amount of seconds.
    1. If the account is not locked out:
      1. Check the failed logins counter. 
      2. For each failed login, pause execution of the authentication method for one second (i.e. for 4 failed logins, pause for 4 seconds). Increment the failed logins counter of the user by 1.
      3. If we have exceeded the maximum number of failed logins in a specified password attempt window, lock the account.
    Successful login attempt:
    1. The user tries to log on with a correct password.
    2. Clear the request's IP address from the log to ensure the legitimate user's IP is not blacklisted.
    3. Check the lockout state of the account.
    4. If the account is locked out, unlock the account. Reset the failed logins counter. Pause for a set amount of seconds before continuing.
    5. If the account is not locked out, pause for 1 second for each failed login attempt. Clear the failed login attempts counter.

    As a clarification, every failed authentication attempt is answered with the same generic message "Your login attempt was not successful. Please try again." (this is the generic message the framework gives). Additionally, if the IP address of the request is blacklisted, it will not allow any authentication attempts.


    Now, what does the above achieve?

    First of all, a malicious user cannot brute force the user's login (unless they are extremely lucky and finds it on the first tries). This is achieved by two mechanisms:
    • Incrementing a failed logins counter and using that to slow down the logon process makes it computationally expensive for a malicious user to try to brute force logins. Each login is penalized, adding to the time necessary to login. The fact that we are also pausing on successful login attempts helps thwart timing attacks (if the attacker sees that the failed login attempt takes more than a couple of seconds while the successful login is instantaneous, the attacker can drop attempts (not wait) that take more than two seconds). Even with the timing attack scenario, it would still be a computationally expensive attack.
    • Logging the IP address of the originating request on failed logins allows the system to essentially blacklist IP addresses. This allows the system to revert account lockouts coming from blacklisted IP addresses, thus the DoS scenario on legitimate user accounts is mitigated.
    Second, returning a generic message in all cases (either failed login on valid user or trying to login using a non-existent username) does not disclose to an attacker any information about the user. If the attacker does not gain access to the actual user account information database or does not have prior knowledge of the existence of a user account, they have no way of deducing the validity of an account. Coupling this with the fact that we randomly delay login attempts of non-existent users further more helps this. (Of course, a careful attacker will notice the randomness of the delays - we could store information on a back-end to provide linear delays as with the other cases).

    Note: I know that there are ways to manipulate the request and spoof the IP Address to thwart blacklisting. I am adding this as an additional (albeir minor) security measure rather than as an end-all-be-all measure.

    Here is the code associated with the events of the built-in Login control the Web Site template adds:

        protected void Page_Load(object sender, EventArgs e)
        {
            // Check if the IP is blacklisted
            // If so, do not allow any more attempts
            if (isIPBlackListed())
                Response.Redirect(@"~\NotAllowed.aspx");
    
            RegisterHyperLink.NavigateUrl = "Register.aspx?ReturnUrl=" + HttpUtility.UrlEncode(Request.QueryString["ReturnUrl"]);
        }
    
        protected void LoginUser_OnLoggingIn(object sender, System.Web.UI.WebControls.LoginCancelEventArgs e)
        {
            // Get the user object
            var user = Membership.GetUser(LoginUser.UserName);
    
            // If it is a valid user
            // Delay the login based on the number of failed attempts
            if (user != null)
            {
                ProfileCommon _profile = (ProfileCommon)System.Web.Profile.ProfileBase.Create(LoginUser.UserName, true);
    
                System.Threading.Thread.Sleep(_profile.FailedLoginAttempts * 1000);
            }
    
        }
    
        protected void LoginUser_OnLogginError(object sender, EventArgs e)
        {
            // Get the user object
            var user = Membership.GetUser(LoginUser.UserName);
    
            // If it is valid user, increase the number of attempts and log the IP
            if (user != null)
            {
                // Only increase when not locked out (no sense in doing so when user is locked out)
                if (!user.IsLockedOut)
                {
                    ProfileCommon _profile = (ProfileCommon)System.Web.Profile.ProfileBase.Create(LoginUser.UserName, true);
    
                    _profile.FailedLoginAttempts++;
                    _profile.Save();
    
                    // Get the request IP Address
                    _dl.increaseIPAddressInvalidAttempts(Request.UserHostAddress);
                }
            }
        }
    
        protected void LoginUser_OnAuthenticate(object sender, AuthenticateEventArgs e)
        {
            // Get the user object
            var user = Membership.GetUser(LoginUser.UserName);
    
            // If it is a valid user
            if (user != null)
            {
                // If the user correctly authenticated
                if (Membership.ValidateUser(LoginUser.UserName, LoginUser.Password))
                {
                    ProfileCommon _profile = (ProfileCommon)System.Web.Profile.ProfileBase.Create(LoginUser.UserName, true);
    
                    // Remove the IP from the (possible) blacklist
                    _dl.removeIPAddressInvalidAttempts(Request.UserHostAddress);
    
                    // If the user is currently locked out
                    if (user.IsLockedOut)
                    {
                        DateTime lockoutDateTime = user.LastLockoutDate;
    
                        // If the lock duration has been exceeded, unlock the user and reset the failed login attempts
                        if (lockoutDateTime.AddMinutes(int.Parse(WebConfigurationManager.AppSettings["LockoutDuration_Minutes"])) < DateTime.Now)
                        {
                            user.UnlockUser();
    
                            _profile.FailedLoginAttempts = 0;
                            _profile.Save();
    
                            // Set the auth cookie if remember me is selected
                            if(LoginUser.RememberMeSet)
                                FormsAuthentication.SetAuthCookie(LoginUser.UserName, false /* createPersistentCookie */);
    
                            e.Authenticated = true;
                        }
                        // If lockout duration has not been exceeded, add delay again
                        // don't lock-out and don't authenticate
                        else
                        {
                            System.Threading.Thread.Sleep(_profile.FailedLoginAttempts * 1000);
                        }
                    }
                    // If the user is not locked out, reset the failed login attempts
                    // and return authenticated
                    else
                    {
                        _profile.FailedLoginAttempts = 0;
                        _profile.Save();
    
                        if (LoginUser.RememberMeSet)
                            FormsAuthentication.SetAuthCookie(LoginUser.UserName, false /* createPersistentCookie */);
    
                        e.Authenticated = true;
                    }
                }
            }
            else
            {
                // Get the request IP Address and increase invalid attempts (log the IP)
                // Possible brute-force attempts
                logIP();
    
                Random r = new Random();
                System.Threading.Thread.Sleep(r.Next(10) * 1000);
            }
        }
    


    Step 5: Password Resets

    This is another sensitive matter. How could this be done securely and correctly. There are proponents of using question/answer pairs so that the user can prove their identity. Having in mind what was discussed in the account creation step, this could actually increase security or provide a false sense of security.

    One thing I would like to point out early on, I consider it wrong to allow password resets without sending an email to the affected user that their password has been changed. If a question/answer pair has been set up (poorly) and an attacker manages to figure out the pair, the attacker can change the user's password. With no notification mechanism, the user would not known that this happened. But, even when notified, we need to provide a mechanism to help the user regain access to their account.

    I propose the following workflow:
    1. Ask the user to enter their email address.
      1. If the email is registered in the system, send an email with a one-time, time-sensitive link to allow the user to reset their password (we could re-use the functionality used on account creation). Display a message that an email has been sent with instructions to reset their email.
      2. If the email is not registered, display the same message as if it existed. This will help mitigating information disclosure (if we responded that the email address does not exist, an attacker would know that the account does not exist, and that an account exists if we show the "email sent" message).
    2. Once the user clicks on the email, if we have set-up question/answer pairs, ask for the user to enter them. If we have not, ask the user to enter a new password.
    3. Send a notification email about the password change.

    The above workflow is as secure as we can be. There is of course the case where the user has lost control of their email to an attacker (we can't secure everything; we have to assume that the user at least safeguards their own email account). Only the user that has registered the account and has access to their email account could possibly reset their password.

    The code to send the password reset link is the same as the code used for the account creation. They only difference is the target page in the link and some of the wording. After the user clicks the link, again the same code is used to verify the cookie and QueryString values, and if verified, the user is allowed the set a new password. The code is available on CodePlex if you want to see the subtle differences (see link at the top of the post).

    In the sample code I have given, I don't rely on question/answer pairs. Though I agree they do increase security if correctly implemented, they can also give a false sense of security in the cases where the user simply does not put the effort in selecting hard-to-know and hard-to-figure-out question/answer pairs.

    Step 6: Two-Factor Authetication - 2FA (optional)


    As an additional (optional) step that greatly increases security, we could allow a user to set-up their account in such a way that additionally to asking for their credentials, we could also ask for a one-time token. I have created another proof-of-concept project available on Codeplex that implements such functionality using Time based One-Time-Passwords with authenticators applications such as Google Authenticator (you can get this from here, with a short blog detailing the functionality here).

    This further more thwarts any effort for brute forcing by an attacker. Even if they could iterate through a password list for a given account, if he is also presented with the requirement of a time-based one-time token, there is no way (short of extreme luck) for the attacker to gain access.

    One minor caveat is the fact that we actually disclose information in this step. We should not ask for the one-time password on successful logins, since this would allow the attacker to successfully first brute force the login (then they "just" have to figure out the one-time password, which would be extremely difficult to do). But, we cannot always ask for a one-time password, since there are users that will not have this option enabled.

    So, coupled with the delays we introduced in the login process, we could reasonably assume that it is safe to request the token after the successful login. The mechanisms set in place to thwart brute forcing the password (delays, account lockouts, blacklisting) would reasonably


    Wrapping things up


    Although this was a long post, I feel that I have touched upon all aspects of the Authentication and Account Life-Cycle process of a web application (feel free to call me on errors or things I may have not considered). The first step in securing our web applications is actually correctly securing access to it.

    Of course, doing just the above does not mean we need do nothing else. We still have to consider SQL Injection, Cross Site Scription (XSS), Cross Site Request Forgery (CSRF) and a multitude of other attacks that could occur if we have an insecure code implementation and/or insecure database access. It is however the first stepping stone towards creating an actually secure site.

    As a last note, we could try implementing other login mechanisms that afford even more security. A great example that I am looking forward to is Steve Gibson's idea of Secure Quick Reliable Login, which does not need username/password pairs, but relies on QR-Codes.

    I hope all of the above was helpful.

    No comments: