Krzysztof
Posted on May 7, 2024
Well hello there! I've recently been doing more and more in Hanami and wanted to share some of my experiences and thoughts about it. So here I am with a blog post about authentication in Hanami (made on version 2.1.1).
Current State of Authentication in Hanami and Rails
There is no Hanami specific authentication library. Rails has a plethora of solutions, but nothing was created for Hanami (at least for the current version). There are framework agnostic tools though. OAuth solutions are like that, JWT, libraries like Rodauth.
The last one is particularly interesting for Hanami since it is very much in the same "spirit" in terms of design and it is also the most advanced solution on the ruby market.
However for smaller apps it might be an overkill. In "real-life" production systems, overengineering is one of the biggest crimes. This is true any framework and technology, so in Rails you might want to use Rodauth since it is big and interesting and challenging, but then again, if you are building a simple greenfield MVP you do not have the time or need, for a big, complex solution. In those cases Rails developers usually go for Devise. It is one of the most known Rails gems, in multiple Rails surveys it was both number 1 in popularity, likability and "most frustrating" rankings.
It is no surprise, since the gem makes some things really, really simple, but some other things... well let's just say it can get in the way. This is the thing with libs that do a lot. There comes a time where they stop helping, and the time saved, dwindles away when trying to bypass their limitations.
Devise is build on top of another library and if you ever had to customize Devise, you probably saw that underlying lib. It is called Warden
Warden
Warden is a general Rack authentication framework that provides a mechanism to hook into any Rack application and handle authentication. It is a simple and flexible framework that allows you to use a wide range of authentication strategies in your applications.
If the times of last updates to the repo worry you, rest assured. It is well maintained, and there is no need for constant updates. It is a mature library that does not need to be updated every week. It is an interesting case for conversation about maintainability of open source. Often we are taken back by the timestamps of last updates on certain gems, libs. We rarely think that they are not getting updated cause there is no need for it. But that is a big topic, for another time.
Right now just keep in mind that Devise depends on Warden so as long as it does, and Devise is maintained, Warden will be too.
We will be using it, since it is a low level lib, requires us to do a lot of things ourselves. Something that Hanami encourages a lot (and enforces). It gives us just enough to boost our work on authentication features, but does not hold us back, by giving us too many things we do not need, does not hide implementations and is very explicit. So it is very inline with Hanami philosophy and design.
Our task
We will be adding a simple authentication (email + password) to a basic Hanami application. This will include handling secure password storage, registering, logging in.
Existing setup
We have a Hanami 2.1 application ready, with persistence added (ROM-RB) and a frontend with daisyUI (TailwindCSS extension). If you want more links to those, check out my previous post about Hanami. In general a new Hanami 2.1 app is enough, as long as you got persistence in it.
Adding Warden
After adding it to our gemfile and running bundle install (I'll skip those for brevity), we need to add few pieces of setup. First we go to our config
# config/app.rb
require "hanami"
require "warden"
module Libus
class App < Hanami::App
config.actions.sessions = :cookie, {
key: "libus.session",
secret: settings.session_secret,
expire_after: 60*60*24*365
}
config.middleware.use Warden::Manager do |manager|
manager.default_strategies :password
manager.failure_app =
lambda do |env|
Libus::Actions::AuthFailure::Show.new.call(env)
end
end
end
end
Also add a line to settings
# config/settings.rb
setting :session_secret, constructor: Types::String
First thing we do in settings is enabling sessions, those are disabled in Hanami by default. Enabling them requires generating a random key. You can use SecureRandom.base64(64)
to generate one quickly, then put in the .env
file under SESSION_SECRET
key. settings.rb
will read it from there and app.rb
takes it from settings. Simple and very clear, something that I really love about Hanami is how it handles setting things up, like plugins, extensions, gems etc.
Another thing we added here is a failure_app
which is a key concept in Warden. Devise does that part for you if you use it, so it is likely you never had to delve into it. Here we need to handle it ourselves, and we will do it by having a simple Hanami Action. This will handle failures on authentication. It requires a special app so it can do whatever you desire with a failure. Usually it is just a redirect back, but maybe some extra logic is required, like tracking attempts. If so, then the failure app is a place to use for it.
But before we implement our failure app, we should register Warden as a provider and add our first strategy. After all we cant test out failure if we do not have a system to log in.
# config/providers/warden.rb
Hanami.app.register_provider(:warden) do
prepare do
require "bcrypt"
require "warden"
end
start do
target.start(:persistence)
Warden::Strategies.add(:password) do
def valid?
params['email'] || params['password']
end
def authenticate!
user_repo = Main::Repositories::Users.new(Hanami.app["persistence.rom"])
user = user_repo.by_email(params["email"])
if user && user.password_hash == BCrypt::Engine.hash_secret(request.params["password"], user.password_salt)
return success!(user)
end
fail!("Could not log in")
end
end
Warden::Manager.serialize_into_session{|user| user.id }
Warden::Manager.serialize_from_session{|id| Main::Repositories::Users.new(Hanami.app["persistence.rom"]).by_id(id) }
end
end
This sets us up with a second most important part of Warden setup. Strategies. We are using a simple password strategy here. We check if the username and password are present, and if they are, we try to authenticate the user. If we fail, we have the failure app kicking in. If we succeed, we return the user, save his data to the session to later reuse.
This is also something that Devise does for you. It has a lot of strategies built in, and you can still add your own on top of it. Here we are doing it ourselves, it really is not that much work to get it working and we avoid a lot of middleware from Devise
Failure App
The most basic way to handle a failed attempt to enter a forbidden place is to show a simple message. Usually most apps just redirect to login page. For now we will just display a simple message for brevity, but it is a regular Hanami Action, so you can redirect here or do whatever, just showcasing that there is no magic here.
module Libus
module Actions
module AuthFailure
class Show < Main::Action
def handle(request, response)
response.body = "STRANGER DANGER"
response.status = 401
end
end
end
end
end
An important distinction is that the failure app is not the same as handling, for example, a missing current_user, on an endpoint that requires it. It is responsible for handling failed logging only, not for user trying to access an endpoint that requires a signed in user.
Database
We need to setup a Users table. Lets run a migration for that.
rake db:create_migration[create_users]
ROM::SQL.migration do
change do
create_table :users do
primary_key :id
column :name, String, null: false
column :email, String, null: false
column :password_hash, String, null: false
column :password_salt, String, null: false
end
end
end
Easy as that. We store a password hash and salt, we need Bcrypt in our app to ensure our password take bazillion years to crack if someone attempts to do it.
Lets setup our User relation and repository.
# lib/libus/persistence/relations/users.rb
module Libus
module Persistence
module Relations
class Users < ROM::Relation[:sql]
schema(:users, infer: true) do
end
end
end
end
end
# lib/libus/persistence/relations/users.rb
module Main
module Repositories
class Users < Main::Repo[:users]
commands :create, update: :by_pk, delete: :by_pk
def query(conditions)
users.where(conditions)
end
def email_taken?(email)
users.exist?(email: email)
end
def by_id(id)
users.by_pk(id).one!
end
end
end
end
Simple relations and repository is all we need to get started.
Specs
Would be nice to test all our authentication features, so lets handle setup for using Warden in our specs.
# spec/support/requests.rb
RSpec.shared_context "Rack::Test" do
# Define the app for Rack::Test requests
let(:app) do
Hanami.boot
Hanami.app
end
end
RSpec.configure do |config|
config.include Rack::Test::Methods, type: :request
config.include_context "Rack::Test", type: :request
config.include Warden::Test::Helpers, type: :request
config.after(:each, type: :request) { Warden.test_reset! }
end
# spec/requests/auth_spec.rb
RSpec.describe 'AuthenticationSpec', :db, type: :request do
context 'when action inherits from authenticated action' do
context "when user is logged in" do
let!(:user) { factory[:user, name: "Guy", email: "my@guy.com"] }
it 'succeeds' do
login_as user
get "/search/isbn", { isbn: "978-0-306-40615-7" }
expect(last_response.status).to be(200)
end
end
context "when there is no user" do
it "rejects the request, redirects" do
get "/search/isbn", { isbn: "978-0-306-40615-7" }
expect(last_response.status).to be(302)
end
end
end
end
Quite simple. The '/search/isbn' request is gonna be validated for user presence, if it fails (user is missing) we will get redirected. For now this test is red since we don't have any routes available only for current users.
Routes
We won't need much, this is how I structured my routes (only showing the relevant ones)
# New user registration
get '/register', to: 'register.new'
post "/users", to: "users.create"
# Session management
get '/login', to: 'login.new'
post "/sessions", to: "sessions.create"
delete "/logout", to: "sessions.destroy"
Actions
Lets start with a basic registration action and spec for it:
RSpec.describe Main::Actions::Users::Create, :db do
it "works with the right params" do
params = {
email: "some@email.com",
name: "John Doe",
password: "password",
password_confirmation: "password"
}
response = subject.call(params)
expect(response).to be_successful
end
context "with bad params" do
let(:params) { Hash[] }
it "fails with missing params" do
response = subject.call({})
expect(response).not_to be_successful
end
end
context "with password missmatch" do
let(:params) {
{ email: "good@email.com", name: "John Doe", password: "somepassword", password_confirmation: "differentthing" }
}
it "fails with password missmatch" do
response = subject.call(params)
expect(response).not_to be_successful
end
end
context "with email already taken" do
let!(:user) { factory[:user, name: "Guy", email: "my@guy.com"] }
let(:params) { { email: "my@guy.com", name: "John Doe", password: "password", password_confirmation: "password" } }
it "fails with" do
response = subject.call(params)
expect(response).not_to be_successful
end
end
end
Basic tests and here is how we can make them pass.
require 'bcrypt'
module Main
module Actions
module Users
class Create < Main::Action
include Deps[users_repo: "repositories.users"]
params do
required(:email).filled(:string)
required(:name).filled(:string)
required(:password).filled(:string)
required(:password_confirmation).filled(:string)
end
def handle(request, response)
halt 422, {errors: request.params.errors}.to_json unless request.params.valid?
halt 422, { errors: "Password must match the confirmation" }.to_json unless request.params[:password] == request.params[:password_confirmation]
halt 422, { errors: "This email is already taken" }.to_json if users_repo.email_taken?(request.params[:email])
password_salt = BCrypt::Engine.generate_salt
password_hash = BCrypt::Engine.hash_secret(request.params[:password], password_salt)
users_repo.create(
name: request.params[:name],
email: request.params[:email],
password_hash: password_hash,
password_salt: password_salt
)
end
end
end
end
end
I've put everything in Action object, instead of separating it into validator object and maybe some user service object, since there is very little logic, and it makes it more readable in blog post form. Once this logic expands, we will need a different abstraction layer.
With this, you just got to get yourself a registration view, with the standard email, name, password, password confirmation fields, and you are good to go. You will get a new user in the database.
To this we can add login and retrieving the current user from session.
module Main
module Actions
module Sessions
class Create < Main::Action
include Deps[users_repo: "repositories.users"]
params do
required(:email).filled(:string)
required(:password).filled(:string)
end
def handle(request, response)
halt 422, {errors: request.params.errors}.to_json unless request.params.valid?
request.env['warden'].authenticate!
user = users_repo.by_email(request.params[:email])
if user && user.password_hash == BCrypt::Engine.hash_secret(request.params[:password], user.password_salt)
request.session[:user_id] = user.id
response.redirect "/"
else
halt 401, "Unauthorized"
end
end
end
end
end
end
If you pair this up with a simple login view, you can normally enter email and password and log in into the app.
We can add a parent action for actions that require authentication that will have
before :authenticate_user
private
def authenticate_user(request, response)
response.redirect_to("/login") unless request.env['warden'].user # request.env['warden'].user is also where you get the current user from. It will be a ROM struct cause of our warden setup from before
end
An examplery action, that will make the previous red spec (the one with redirection) green
module Main
module Actions
module IsbnSearch
class Show < Libus::Actions::AuthenticatedAction
params do
required(:isbn).filled(:string)
end
def handle(request, response)
halt 422, {errors: request.params.errors}.to_json unless request.params.valid?
Main::Workers::IsbnSearch.perform_async(request.params[:isbn])
response.render(view, isbn: request.params[:isbn])
end
end
end
end
end
Log out
Logging out is the simplest part of it all.
In my navbar I have a bit that goes like this:
<% if warden.user %>
<li class="justify-center">
<%= form_for :logout, 'logout', method: :delete do %>
<button type="submit">Logout</button>
<% end %>
</li>
<% else %>
<li class="justify-center"><a href="/login">Login</a></li>
<% end %>
Which I handle with:
module Main
module Actions
module Sessions
class Destroy < Main::Action
def handle(request, response)
request.env['warden'].logout
response.redirect_to("/")
end
end
end
end
end
Conclusion
Warden requires very little to start working, and in some parts of the code presented, you might have seen, or thought, that Devise does not really add that much there, other than some convenience. It is not exactly true, since Devise gives us (in Rails) the whole emailing system, views, resetting password, flash messages setting, password handling etc. We often modify a lot of those things and most of them rarely become an actually relevant time saver. Building an email system on your own does not take a lot of time. Modifying an existing one, that you did not wrote, and that has its code hidden in lib source code, can be far more time consuming.
Using Devise has often been a gamble for me. I either save a lot of time, or waste a bit of it. When working on Rails I have no problems using it anyway, since I have also become somewhat proficient in modifying it, but in Hanami, when I have no access to it I can better see what are the drawbacks and benefits of it. Also going on level lower in terms of libs used, gives a better understanding of systems and technologies used (sessions, password storing etc).
I am convinced that every RoR developer should at least once build their own authentication system without Devise since it makes certain things too easy and hides a lot of implementation details, that lead to better understanding of cookies, session and using those stuff, understanding where they are available are where they come from. What really happens when you call current_user
and how was it set up? What can we do with data stored in session and what is stored there for the authorization purposes?
The system presented here by me is a skeleton, a starting point, to potentially complex and robust system. Feel free to fork the repo from this point and see how difficult it is to connect emails and better views, flash messages (it really isn't). It does not cost a lot of time, but gives you far better control and understanding of what you actually have in your system, while also cutting your dependencies short (devise uses more than just warden), and making your codebase generally smaller, easier to go through, expand, modify.
Posted on May 7, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.