Last active
November 9, 2024 12:35
-
-
Save mudge/2ca7c691f4bb5862b71dc2c51c8d5a08 to your computer and use it in GitHub Desktop.
Transparently upgrade from bcrypt to Argon2id with Active Record
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
require "argon2id" | |
require "bcrypt" | |
# Schema: User(name: string, password_digest:string) | |
class User < ApplicationRecord | |
attr_reader :password | |
validates :password_digest, presence: true | |
validates :password, confirmation: true, allow_blank: true | |
def password=(unencrypted_password) | |
if unencrypted_password.nil? | |
@password = nil | |
self.password_digest = nil | |
elsif !unencrypted_password.empty? | |
@password = unencrypted_password | |
self.password_digest = Argon2id::Password.create(unencrypted_password) | |
end | |
end | |
def authenticate_password(unencrypted_password) | |
return false unless password_digest? | |
if argon2id_password_digest? | |
Argon2id::Password.new(password_digest).is_password?(unencrypted_password) && self | |
elsif BCrypt::Password.new(password_digest).is_password?(unencrypted_password) | |
update_attribute(:password, unencrypted_password) | |
self | |
else | |
false | |
end | |
end | |
def password_salt | |
return unless password_digest? | |
if argon2id_password_digest? | |
Argon2id::Password.new(password_digest).salt | |
else | |
BCrypt::Password.new(password_digest).salt | |
end | |
end | |
def argon2id_password_digest? | |
Argon2id::Password.valid_hash?(password_digest) | |
end | |
alias_method :authenticate, :authenticate_password | |
end |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
require "test_helper" | |
class UserTest < ActiveSupport::TestCase | |
test "is invalid if password digest is blank" do | |
user = User.new | |
user.password_digest = "" | |
assert user.invalid? | |
end | |
test "is invalid if password confirmation does not match" do | |
user = User.new | |
user.password = "password" | |
user.password_confirmation = "diffpassword" | |
assert user.invalid? | |
end | |
test "is valid if password is set" do | |
user = User.new | |
user.password = "password" | |
assert user.valid? | |
end | |
test "is valid if password and confirmation match" do | |
user = User.new | |
user.password = "password" | |
user.password_confirmation = "password" | |
assert user.valid? | |
end | |
test "setting password to a non-empty string sets the password" do | |
user = User.new | |
user.password = "password" | |
assert_equal "password", user.password | |
end | |
test "setting password to a non-empty string sets the password digest" do | |
user = User.new | |
user.password = "password" | |
assert_not_empty "password", user.password_digest | |
end | |
test "setting password to nil clears the password" do | |
user = User.new(password: "password") | |
user.password = nil | |
assert_nil user.password | |
end | |
test "setting password to nil clears the password digest" do | |
user = User.new(password_digest: "$password$digest") | |
user.password = nil | |
assert_nil user.password_digest | |
end | |
test "setting the password to an empty string doesn't change the password" do | |
user = User.new(password: "password") | |
user.password = "" | |
assert_equal "password", user.password | |
end | |
test "setting the password to an empty string doesn't change the password digest" do | |
user = User.new(password_digest: "$password$digest") | |
user.password = "" | |
assert_equal "$password$digest", user.password_digest | |
end | |
test "authenticating Argon2id hash with correct password returns the user" do | |
user = User.new( | |
password_digest: "$argon2id$v=19$m=256,t=2,p=1$c29tZXNhbHQ$nf65EOgLrQMR/uIPnA4rEsF5h7TKyQwu9U1bMCHGi/4" | |
) | |
assert_equal user, user.authenticate("password") | |
end | |
test "authenticating Argon2id hash with incorrect password returns false" do | |
user = User.new( | |
password_digest: "$argon2id$v=19$m=256,t=2,p=1$c29tZXNhbHQ$nf65EOgLrQMR/uIPnA4rEsF5h7TKyQwu9U1bMCHGi/4" | |
) | |
assert_not user.authenticate("diffpassword") | |
end | |
test "authenticating bcrypt hash with correct password returns user" do | |
user = User.new( | |
password_digest: "$2a$12$stsRn7Mi9r02.keRyF4OK.Aq4UWOU185lWggfUQfcupAi.b7AI/nS" | |
) | |
assert_equal user, user.authenticate("password") | |
end | |
test "authenticating bcrypt hash with incorrect password returns false" do | |
user = User.new( | |
password_digest: "$2a$12$stsRn7Mi9r02.keRyF4OK.Aq4UWOU185lWggfUQfcupAi.b7AI/nS" | |
) | |
assert_not user.authenticate("diffpassword") | |
end | |
test "authenticating bcrypt hash with correct password rehashes password with Argon2id" do | |
user = User.new( | |
password_digest: "$2a$12$stsRn7Mi9r02.keRyF4OK.Aq4UWOU185lWggfUQfcupAi.b7AI/nS" | |
) | |
user.authenticate("password") | |
assert user.password_digest.start_with?("$argon2id$") | |
end | |
test "rehashed password still matches old plain text password" do | |
user = User.new( | |
password_digest: "$2a$12$stsRn7Mi9r02.keRyF4OK.Aq4UWOU185lWggfUQfcupAi.b7AI/nS" | |
) | |
user.authenticate("password") | |
assert_equal user, user.authenticate("password") | |
end | |
test "authenticating bcrypt hash with incorrect password does not rehash password" do | |
user = User.new( | |
password_digest: "$2a$12$stsRn7Mi9r02.keRyF4OK.Aq4UWOU185lWggfUQfcupAi.b7AI/nS" | |
) | |
user.authenticate("diffpassword") | |
assert_equal "$2a$12$stsRn7Mi9r02.keRyF4OK.Aq4UWOU185lWggfUQfcupAi.b7AI/nS", user.password_digest | |
end | |
test "password salt is nil if there is no password digest" do | |
user = User.new | |
assert_nil user.password_salt | |
end | |
test "password salt is nil if the password digest is blank" do | |
user = User.new | |
user.password_digest = "" | |
assert_nil user.password_salt | |
end | |
test "password salt returns the salt from an Argon2id hash" do | |
user = User.new( | |
password_digest: "$argon2id$v=19$m=256,t=2,p=1$c29tZXNhbHQ$nf65EOgLrQMR/uIPnA4rEsF5h7TKyQwu9U1bMCHGi/4" | |
) | |
assert_equal "somesalt", user.password_salt | |
end | |
test "password salt returns the salt from bcrypt hash" do | |
user = User.new( | |
password_digest: "$2a$12$stsRn7Mi9r02.keRyF4OK.Aq4UWOU185lWggfUQfcupAi.b7AI/nS" | |
) | |
assert_equal "$2a$12$stsRn7Mi9r02.keRyF4OK.", user.password_salt | |
end | |
test "is an Argon2id password digest with a valid Argon2id hash" do | |
user = User.new( | |
password_digest: "$argon2id$v=19$m=256,t=2,p=1$c29tZXNhbHQ$nf65EOgLrQMR/uIPnA4rEsF5h7TKyQwu9U1bMCHGi/4" | |
) | |
assert user.argon2id_password_digest? | |
end | |
test "is not an Argon2id password digest with a bcrypt hash" do | |
user = User.new( | |
password_digest: "$2a$12$stsRn7Mi9r02.keRyF4OK.Aq4UWOU185lWggfUQfcupAi.b7AI/nS" | |
) | |
assert_not user.argon2id_password_digest? | |
end | |
test "is not an Argon2id password digest with no password digest" do | |
user = User.new(password_digest: nil) | |
assert_not user.argon2id_password_digest? | |
end | |
test "is not an Argon2id password digest with a blank password digest" do | |
user = User.new(password_digest: "") | |
assert_not user.argon2id_password_digest? | |
end | |
end |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment