Reputation: 43
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!
Upvotes: 3
Views: 3138
Reputation: 1728
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);
}
Upvotes: 1
Reputation: 43
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.
Upvotes: 1