3

I've got a production application that I'm looking to re-build (ground up) on MVC4. Usage of the SimpleMembershipProvider for authentication and authorization seems to be very suitable for my needs, except for one thing: password encryption.

The current production version of the application has a custom MembershipProvider that encrypted passwords and stored them by generating a salt, hashing the password with the salt (SHA256) and then storing the salt as the first X characters of the database-stored password:

MyApp.Security.MyAppMembershipProvider : System.Web.Security.MembershipProvider:

public override MembershipUser CreateUser(string username, string password, string email, string passwordQuestion, string passwordAnswer, bool isApproved, object providerUserKey, out MembershipCreateStatus status) {

    // ...

    u.Email = email.ToLower();

    string salt = GenerateSalt();
    u.Password = salt + Helper.FormatPassword(salt, password, this.PasswordFormat);
    u.FirstName = String.Empty;
    u.LastName = String.Empty;

    // ...

}

As I convert the application over to MVC4, the obvious issue is that I want my users' old passwords to continue to authenticate them. I'm willing to migrate to a new data schema, but legacy authentication information will need to continue to work.

My question is, is it possible to override the same way with SimpleMembershipProvider? Will I have to use an implementation of ExtendedMembershipProvider? Or, fingers crossed, is there some voodoo easy way I can do this without creating a custom membership provider altogether?

Thanks!

TalonFinsky
  • 43
  • 1
  • 5
  • Your problem is that the users from the old schema have a different password encryption ? the question is not clear enough... – Mortalus Mar 06 '13 at 20:11
  • Yes, mostly. The encryption on the old version's membership stores the salt as the first 8 characters of the stored password. The createaccount, changepassword and validation methods of system.web.security.membershipprovider are all overridden to account for this. The question is whether or not I can extend simplemembershipprovider's versions of these methods to account for this (and thus keep credentials the same for existing users) or, more generally, what the best method for accomplishing this is in mvc4 membership. – TalonFinsky Mar 06 '13 at 21:06

2 Answers2

1

I think I'm going to go a slightly different route after all:

http://pretzelsteelersfan.blogspot.com/2012/11/migrating-legacy-apps-to-new.html

Basically, migrating legacy user data as-is to the UserProfile table and creating a class to validate credentials against the old algorithm if the SimpleMembership validation fails. If legacy validation succeeds, updating password to new algorithm via WebSecurity.ResetToken to modernize it.

Thanks for the help.

TalonFinsky
  • 43
  • 1
  • 5
1

What you are looking for is to implement your own ExtendedMembershipProvider. There doesn't appear to be any way to interfere with the SimpleMembershipProvider's encryption method, so you need to write your own (such as PBKDF2). I chose to store the salt along with the PBKDF2 iterations in the PasswordSalt column of webpages_Membership, and that way you can increase this value later on when computers get faster and upgrade your old passwords on the fly.

Such a template example might look like:

    using WebMatrix.Data;
    using WebMatrix.WebData;
    using SimpleCrypto;
    public class CustomAuthenticationProvider : ExtendedMembershipProvider
    {
        private string applicationName = "CustomAuthenticationProvider";
        private string connectionString = "";
        private int HashIterations = 10000;
        private int SaltSize = 64;

        public override void Initialize(string name, System.Collections.Specialized.NameValueCollection config)
        {
            try
            {
                if (config["connectionStringName"] != null)
                    this.connectionString = ConfigurationManager.ConnectionStrings[config["connectionStringName"]].ConnectionString;
            }
            catch (Exception ex)
            {
                throw new Exception(String.Format("Connection string '{0}' was not found.", config["connectionStringName"]));
            }
            if (config["applicationName"] != null)
                this.connectionString = ConfigurationManager.ConnectionStrings[config["applicationName"]].ConnectionString;

            base.Initialize(name, config);
        }

        public override bool ConfirmAccount(string accountConfirmationToken)
        {
            return true;
        }

        public override bool ConfirmAccount(string userName, string accountConfirmationToken)
        {
            return true;
        }

        public override string CreateAccount(string userName, string password, bool requireConfirmationToken)
        {
            throw new NotImplementedException();
        }

        public override string CreateUserAndAccount(string userName, string password, bool requireConfirmation, IDictionary<string, object> values)
        {
            // Hash the password using our currently configured salt size and hash iterations
            PBKDF2 crypto = new PBKDF2();
            crypto.HashIterations = HashIterations;
            crypto.SaltSize = SaltSize;
            string hash = crypto.Compute(password);
            string salt = crypto.Salt;

            using (SqlConnection con = new SqlConnection(this.connectionString))
            {
                con.Open();
                int userId = 0;
                // Create the account in UserProfile
                using (SqlCommand sqlCmd = new SqlCommand("INSERT INTO UserProfile (UserName) VALUES(@UserName); SELECT CAST(SCOPE_IDENTITY() AS INT);", con))
                {
                    sqlCmd.Parameters.AddWithValue("UserName", userName);
                    object ouserId = sqlCmd.ExecuteScalar();
                    if (ouserId != null)
                        userId = (int)ouserId;
                }
                // Create the membership account and associate the password information
                using (SqlCommand sqlCmd = new SqlCommand("INSERT INTO webpages_Membership (UserId, CreateDate, Password, PasswordSalt) VALUES(@UserId, GETDATE(), @Password, @PasswordSalt);", con))
                {
                    sqlCmd.Parameters.AddWithValue("UserId", userId);
                    sqlCmd.Parameters.AddWithValue("Password", hash);
                    sqlCmd.Parameters.AddWithValue("PasswordSalt", salt);
                    sqlCmd.ExecuteScalar();
                }
                con.Close();
            }
            return "";
        }

        public override bool ChangePassword(string username, string oldPassword, string newPassword)
        {
            // Hash the password using our currently configured salt size and hash iterations
            PBKDF2 crypto = new PBKDF2();
            crypto.HashIterations = HashIterations;
            crypto.SaltSize = SaltSize;
            string oldHash = crypto.Compute(oldPassword);
            string salt = crypto.Salt;
            string newHash = crypto.Compute(oldPassword);

            using (SqlConnection con = new SqlConnection(this.connectionString))
            {
                con.Open();
                con.Close();
            }
            return true;
        }

        public override bool ValidateUser(string username, string password)
        {
            bool validCredentials = false;
            bool rehashPasswordNeeded = false;
            DataTable userTable = new DataTable();

            // Grab the hashed password from the database
            using (SqlConnection con = new SqlConnection(this.connectionString))
            {
                con.Open();
                using (SqlCommand sqlCmd = new SqlCommand("SELECT m.Password, m.PasswordSalt FROM webpages_Membership m INNER JOIN UserProfile p ON p.UserId=m.UserId WHERE p.UserName=@UserName;", con))
                {
                    sqlCmd.Parameters.AddWithValue("UserName", username);
                    using (SqlDataAdapter adapter = new SqlDataAdapter(sqlCmd))
                    {
                        adapter.Fill(userTable);
                    }
                }

                con.Close();
            }

            // If a username match was found, check the hashed password against the cleartext one provided
            if (userTable.Rows.Count > 0)
            {
                DataRow row = userTable.Rows[0];

                // Hash the cleartext password using the salt and iterations provided in the database
                PBKDF2 crypto = new PBKDF2();
                string hashedPassword = row["Password"].ToString();
                string dbHashedPassword = crypto.Compute(password, row["PasswordSalt"].ToString());

                // Check if the hashes match
                if (hashedPassword.Equals(dbHashedPassword))
                    validCredentials = true;

                // Check if the salt size or hash iterations is different than the current configuration
                if (crypto.SaltSize != this.SaltSize || crypto.HashIterations != this.HashIterations)
                    rehashPasswordNeeded = true;
            }

            if (rehashPasswordNeeded)
            {
                // rehash and update the password in the database to match the new requirements.
                // todo: update database with new password
            }

            return validCredentials;
        }
}

And the encryption class as follows (in my case I used a PBKDF2 encryption wrapper called SimpleCrypto):

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using System.Threading.Tasks;

namespace SimpleCrypto
{

    /// <summary>
    /// 
    /// </summary>
    public class PBKDF2 : ICryptoService
    {
        /// <summary>
        /// Initializes a new instance of the <see cref="PBKDF2"/> class.
        /// </summary>
        public PBKDF2()
        {
            //Set default salt size and hashiterations
            HashIterations = 100000;
            SaltSize = 34;
        }

        /// <summary>
        /// Gets or sets the number of iterations the hash will go through
        /// </summary>
        public int HashIterations
        { get; set; }

        /// <summary>
        /// Gets or sets the size of salt that will be generated if no Salt was set
        /// </summary>
        public int SaltSize
        { get; set; }

        /// <summary>
        /// Gets or sets the plain text to be hashed
        /// </summary>
        public string PlainText
        { get; set; }

        /// <summary>
        /// Gets the base 64 encoded string of the hashed PlainText
        /// </summary>
        public string HashedText
        { get; private set; }

        /// <summary>
        /// Gets or sets the salt that will be used in computing the HashedText. This contains both Salt and HashIterations.
        /// </summary>
        public string Salt
        { get; set; }


        /// <summary>
        /// Compute the hash
        /// </summary>
        /// <returns>
        /// the computed hash: HashedText
        /// </returns>
        /// <exception cref="System.InvalidOperationException">PlainText cannot be empty</exception>
        public string Compute()
        {
            if (string.IsNullOrEmpty(PlainText)) throw new InvalidOperationException("PlainText cannot be empty");

            //if there is no salt, generate one
            if (string.IsNullOrEmpty(Salt))
                GenerateSalt();

            HashedText = calculateHash(HashIterations);

            return HashedText;
        }


        /// <summary>
        /// Compute the hash using default generated salt. Will Generate a salt if non was assigned
        /// </summary>
        /// <param name="textToHash"></param>
        /// <returns></returns>
        public string Compute(string textToHash)
        {
            PlainText = textToHash;
            //compute the hash
            Compute();
            return HashedText;
        }


        /// <summary>
        /// Compute the hash that will also generate a salt from parameters
        /// </summary>
        /// <param name="textToHash">The text to be hashed</param>
        /// <param name="saltSize">The size of the salt to be generated</param>
        /// <param name="hashIterations"></param>
        /// <returns>
        /// the computed hash: HashedText
        /// </returns>
        public string Compute(string textToHash, int saltSize, int hashIterations)
        {
            PlainText = textToHash;
            //generate the salt
            GenerateSalt(hashIterations, saltSize);
            //compute the hash
            Compute();
            return HashedText;
        }

        /// <summary>
        /// Compute the hash that will utilize the passed salt
        /// </summary>
        /// <param name="textToHash">The text to be hashed</param>
        /// <param name="salt">The salt to be used in the computation</param>
        /// <returns>
        /// the computed hash: HashedText
        /// </returns>
        public string Compute(string textToHash, string salt)
        {
            PlainText = textToHash;
            Salt = salt;
            //expand the salt
            expandSalt();
            Compute();
            return HashedText;
        }

        /// <summary>
        /// Generates a salt with default salt size and iterations
        /// </summary>
        /// <returns>
        /// the generated salt
        /// </returns>
        /// <exception cref="System.InvalidOperationException"></exception>
        public string GenerateSalt()
        {
            if (SaltSize < 1) throw new InvalidOperationException(string.Format("Cannot generate a salt of size {0}, use a value greater than 1, recommended: 16", SaltSize));

            var rand = RandomNumberGenerator.Create();

            var ret = new byte[SaltSize];

            rand.GetBytes(ret);

            //assign the generated salt in the format of {iterations}.{salt}
            Salt = string.Format("{0}.{1}", HashIterations, Convert.ToBase64String(ret));

            return Salt;
        }

        /// <summary>
        /// Generates a salt
        /// </summary>
        /// <param name="hashIterations">the hash iterations to add to the salt</param>
        /// <param name="saltSize">the size of the salt</param>
        /// <returns>
        /// the generated salt
        /// </returns>
        public string GenerateSalt(int hashIterations, int saltSize)
        {
            HashIterations = hashIterations;
            SaltSize = saltSize;
            return GenerateSalt();
        }

        /// <summary>
        /// Get the time in milliseconds it takes to complete the hash for the iterations
        /// </summary>
        /// <param name="iteration"></param>
        /// <returns></returns>
        public int GetElapsedTimeForIteration(int iteration)
        {
            var sw = new Stopwatch();
            sw.Start();
            calculateHash(iteration);
            return (int)sw.ElapsedMilliseconds;
        }


        private string calculateHash(int iteration)
        {
            //convert the salt into a byte array
            byte[] saltBytes = Encoding.UTF8.GetBytes(Salt);

            using (var pbkdf2 = new Rfc2898DeriveBytes(PlainText, saltBytes, iteration))
            {
                var key = pbkdf2.GetBytes(64);
                return Convert.ToBase64String(key);
            }
        }

        private void expandSalt()
        {
            try
            {
                //get the position of the . that splits the string
                var i = Salt.IndexOf('.');

                //Get the hash iteration from the first index
                HashIterations = int.Parse(Salt.Substring(0, i), System.Globalization.NumberStyles.Number);

            }
            catch (Exception)
            {
                throw new FormatException("The salt was not in an expected format of {int}.{string}");
            }
        }


    }
}

and it wouldn't be complete without the interface:

public interface ICryptoService
    {
        /// <summary>
        /// Gets or sets the number of iterations the hash will go through
        /// </summary>
        int HashIterations { get; set; }

        /// <summary>
        /// Gets or sets the size of salt that will be generated if no Salt was set
        /// </summary>
        int SaltSize { get; set; }

        /// <summary>
        /// Gets or sets the plain text to be hashed
        /// </summary>
        string PlainText { get; set; }

        /// <summary>
        /// Gets the base 64 encoded string of the hashed PlainText
        /// </summary>
        string HashedText { get; }

        /// <summary>
        /// Gets or sets the salt that will be used in computing the HashedText. This contains both Salt and HashIterations.
        /// </summary>
        string Salt { get; set; }

        /// <summary>
        /// Compute the hash
        /// </summary>
        /// <returns>the computed hash: HashedText</returns>
        string Compute();

        /// <summary>
        /// Compute the hash using default generated salt. Will Generate a salt if non was assigned
        /// </summary>
        /// <param name="textToHash"></param>
        /// <returns></returns>
        string Compute(string textToHash);

        /// <summary>
        /// Compute the hash that will also generate a salt from parameters
        /// </summary>
        /// <param name="textToHash">The text to be hashed</param>
        /// <param name="saltSize">The size of the salt to be generated</param>
        /// <param name="hashIterations"></param>
        /// <returns>the computed hash: HashedText</returns>
        string Compute(string textToHash, int saltSize, int hashIterations);

        /// <summary>
        /// Compute the hash that will utilize the passed salt
        /// </summary>
        /// <param name="textToHash">The text to be hashed</param>
        /// <param name="salt">The salt to be used in the computation</param>
        /// <returns>the computed hash: HashedText</returns>
        string Compute(string textToHash, string salt);

        /// <summary>
        /// Generates a salt with default salt size and iterations
        /// </summary>
        /// <returns>the generated salt</returns>
        string GenerateSalt();

        /// <summary>
        /// Generates a salt
        /// </summary>
        /// <param name="hashIterations">the hash iterations to add to the salt</param>
        /// <param name="saltSize">the size of the salt</param>
        /// <returns>the generated salt</returns>
        string GenerateSalt(int hashIterations, int saltSize);

        /// <summary>
        /// Get the time in milliseconds it takes to complete the hash for the iterations
        /// </summary>
        /// <param name="iteration"></param>
        /// <returns></returns>
        int GetElapsedTimeForIteration(int iteration);
    }
Michael Brown
  • 1,585
  • 1
  • 22
  • 36
  • Yep, that was exactly what I was looking for. Marked as answer. In the end, I went with a more expensive method that used direct access to the webpages_Membership table to check credentials with our legacy encryption if the base SimpleMembership checks failed. If that passed, we reset the password to the new method. Thanks! – TalonFinsky Sep 20 '13 at 01:20
  • Why do you call hashing encrypting? Why aren't you using entity framework? Why you are writing whole pages of code that could be done in 2 lines? Why are you using a convoluted 3rd party wrapper when MS built PBKDF2 into their security framework as Rfc2898DeriveBytes? Plus you know nobody recommends pbkdf anymore right? You're supposed to be using bcrypt, scrypt, or blake. – 8vtwo Mar 11 '21 at 03:45