Move from MD5 to bcrypt hashed password without bothering users
In an application we wrote back in 2004 I found MD5 hashed passwords. We decided this was too weak for modern standards so we wanted to switch to bcrypt. During the move we wanted the user to be affected as little as possible.
In order to compute the crypted password we need the cleartext version. We only have a hashed version so the user has to type her password. Luckily they do this every time they authenticate, so that is a nice opportunity to upgrade their password.
First I added a crypted_password
column to the accounts
table. We now have two columns for storing the password: the old hashed_password
and the new crypted_password
.
add_column :accounts, :crypted_password, :string
After that we updated the password accessor methods; assignment and verification.
class Account
def password=(password)
if new_record? or !password.blank?
self.crypted_password = BCrypt::Password.create(password)
end
end
def has_password?(input)
BCrypt::Password.new(crypted_password) == input
rescue BCrypt::Errors::InvalidHash
false
end
end
Now need to make sure we can authenticate with both the hashed as well as the crypted password stored for an account.
class Account
def self.authenticate_with_crypt(params={})
if account = find_by_username(params[:username]) and
account.has_password?(params[:password])
account
end
end
def self.hash_password(password)
::Digest::MD5.hexdigest(password)
end
def self.authenticate_with_md5(params={})
find_by_username_and_hashed_password(
params[:username],
hash_password(params[:password])
)
end
end
Finally we need to make sure the password automatically updates. We try to authenticate using bcrypt. BCrypt raises an exception when the crypted_password is blank. This makes authentication fail and we fall back to trying the hashed password. When authentication with a hashed password succeeds we know the cleartext password and we can update it.
class Account
def self.authenticate(params={})
if account = authenticate_with_crypt(params)
account
elsif account = authenticate_with_md5(params)
account.password = params[:password]
account.hashed_password = nil
account.save!
account
else
account = Account.new(params.slice(:username, :password))
account.errors.add_to_base("The credentials you entered are"
"invalid. Please try again.")
account
end
end
end
This solution will leave a group of users with a hashed password indefinitely. After a few months we could decide to throw away the hashed passwords. This means that infrequent users will have to reset their password if they do decide to log in again. It could cause some support requests, but I think we can handle them.
Note that this change doesn’t make the application safer. In case we leak information or when the database is somehow stolen it will make it harder to recover passwords. A lot of people use the same password for multiple accounts so this will give them time to reset their other accounts in case it is compromised. The change only took 15 minutes in this application so it’s totally worth the time.