Migrating Hanami from Warden to Rodauth
I recently migrated a small app that I wrote to help me track ADHD symptoms from a very basic authentication with Warden to a rodauth based solution.
I am a big fan of rodauth, as it has the same design sensibilities as Jeremy’s other awesome gems, roda and sequel.
Since switching authentication systems is a bit trickier than adding one from scratch I thought it might be helpful to compile some of my notes for others who are planning to go on this journey.
Preparations#
When I initially added the Warden authentication, I went with User as the model name because that was also used in the tutorial. Roda’s default for this model is Account, and ever since watching watching the “User” episode of RubyTapas, I agree that Account is the better naming.
So to make the transition to rodauth easier, the first was to rename User to Account.
for each desired change, make the change easy (warning: this may be hard), then make the easy change
– Kent Beck
After the name change, I noticed that my warden solution was using two database fields for storing the bcrypt hash (password_hash and password_salt) but rodauth only needed the password_hash column. Turns out that the bcrypt hash already contains the salt, and the bcrypt-ruby gem has a really nice interface for working with bcrypt hashes.
So the next step was to replace
password_salt = BCrypt::Engine.generate_salt
password_hash = BCrypt::Engine.hash_secret(request.params[:account][:password], password_salt)
with the much nicer
password_hash = BCrypt::Password.create(request.params[:account][:password])
and the password checking code
account.password_hash == BCrypt::Engine.hash_secret(request.params["password"], account.password_salt)
with simply
BCrypt::Password.new(account.password_hash) == request.params["password"]
With all specs green, it is now time for the main event
Going full rodauth#
This part was mostly straightforward thanks to Tim’s tutorial. Since I already had existing users in the database, I spent a lot of time ensuring that the migrations were all clean and worked in both directions, in case I ever decided to move back to warden.
Account status#
The tutorial configuration uses the verify_account plugin, and since that’s a pretty useful feature, I kept that as well. To make sure that the existing users were properly verified, the migration not only sets all existing accounts to the verified status, but also generated an entry in the account_verrification_keys table, as if they went through the complete rodauth process.
Since I wanted this to be as close as possible to how the table would look if rodauth had created the entries, I reimplemented SecureRandom.urlsafe_base64 in SQL:
INSERT INTO account_verification_keys (id, key)
SELECT DISTINCT id, translate(encode(gen_random_bytes(32), 'base64'), '+/=', '-_') as key
FROM "accounts"
WHERE ("status_id" IN (1, 2));
gen_random_bytes is a function from the pgcrypto extension, so for this to work, the migration also needs to install that extension:
CREATE EXTENSION IF NOT EXISTS pgcrypto;
Database Cleaner#
One thing I noticed while running the tests was that somehow the account_statuses kept getting wiped, even though it shouldn’t have been. During a code dive into Database Cleaner, I also learned that it had Sequel’s schema_migrations table already in a list of tables that should not be truncated.
So after a few changes, the working spec/support/db/cleaning.rb looked like this
# frozen_string_literal: true
require "database_cleaner/sequel"
# Clean the databases between tests tagged as `:db`
RSpec.configure do |config|
skip_cleaning_tables = [
"account_statuses"
]
# Returns all the configured databases across the app and its slices.
#
# Used in the before/after hooks below to ensure each database is cleaned between examples.
#
# Modify this proc (or any code below) if you only need specific databases cleaned.
all_databases = -> {
slices = [Hanami.app] + Hanami.app.slices.with_nested
slices.each_with_object([]) { |slice, dbs|
next unless slice.key?("db.rom")
dbs.concat slice["db.rom"].gateways.values.map(&:connection)
}.uniq
}
config.before :suite do
all_databases.call.each do |db|
DatabaseCleaner[:sequel, db: db].clean_with :truncation, except: skip_cleaning_tables
end
end
config.before :each, :db do |example|
strategy = example.metadata[:js] ? :truncation : :transaction
all_databases.call.each do |db|
DatabaseCleaner[:sequel, db: db].strategy = strategy, { except: skip_cleaning_tables }
DatabaseCleaner[:sequel, db: db].start
end
end
config.after :each, :db do
all_databases.call.each do |db|
DatabaseCleaner[:sequel, db: db].clean
end
end
end
A Matter Of Factories1#
When working with factories and passwords, having transient attributes is a pretty nice quality of life. That way, you can write a factory like this:
require "bcrypt"
Factory.define(:account, struct_namespace: AdhDiary::Structs) do |f|
f.name { fake(:name, :name) }
f.email { fake(:internet, :email) }
f.password_hash { fake(:lorem, :sentence) }
f.password_hash { |password| BCrypt::Password.create(password) }
f.locale "en"
f.status_id 2
f.association(:identities, count: 1)
f.transient do |t|
t.password "gnarf"
end
end
rom-factory, the factory library used in Hanami, does not have transient attributes yet, but I have a working implementation in my branch and am currently in the process of creating a PR to add this functionality into the main rom-factory gem.
Conclusion#
The combination of Hanami and rodauth is fantastic. The process of switching was a lot easier than I had feared. And I have only scratched the surface of rodauth. The next action is probably adding WebAuthn support to my site, just to see how easy rodauth makes this.