
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 likeRodauth.
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 useRodauth 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 forDevise. It is one of the most known Rails gems, in multiple Railssurveys 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 customizeDevise, you probably saw that underlying lib. It is calledWarden
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 thatDevise depends onWarden so as long as it does, andDevise 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.rbrequire"hanami"require"warden"moduleLibusclassApp<Hanami::Appconfig.actions.sessions=:cookie,{key:"libus.session",secret:settings.session_secret,expire_after:60*60*24*365}config.middleware.useWarden::Managerdo|manager|manager.default_strategies:passwordmanager.failure_app=lambdado|env|Libus::Actions::AuthFailure::Show.new.call(env)endendendend
Also add a line to settings
# config/settings.rbsetting: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 useSecureRandom.base64(64)
to generate one quickly, then put in the.env
file underSESSION_SECRET
key.settings.rb
will read it from there andapp.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 afailure_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 aprovider 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.rbHanami.app.register_provider(:warden)dopreparedorequire"bcrypt"require"warden"endstartdotarget.start(:persistence)Warden::Strategies.add(:password)dodefvalid?params['email']||params['password']enddefauthenticate!user_repo=Main::Repositories::Users.new(Hanami.app["persistence.rom"])user=user_repo.by_email(params["email"])ifuser&&user.password_hash==BCrypt::Engine.hash_secret(request.params["password"],user.password_salt)returnsuccess!(user)endfail!("Could not log in")endendWarden::Manager.serialize_into_session{|user|user.id}Warden::Manager.serialize_from_session{|id|Main::Repositories::Users.new(Hanami.app["persistence.rom"]).by_id(id)}endend
This sets us up with a second most important part ofWarden 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 thatDevise 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 fromDevise
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.
moduleLibusmoduleActionsmoduleAuthFailureclassShow<Main::Actiondefhandle(request,response)response.body="STRANGER DANGER"response.status=401endendendendend
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.migrationdochangedocreate_table:usersdoprimary_key:idcolumn:name,String,null:falsecolumn:email,String,null:falsecolumn:password_hash,String,null:falsecolumn:password_salt,String,null:falseendendend
Easy as that. We store a password hash and salt, we needBcrypt 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.rbmoduleLibusmodulePersistencemoduleRelationsclassUsers<ROM::Relation[:sql]schema(:users,infer:true)doendendendendend
# lib/libus/persistence/relations/users.rbmoduleMainmoduleRepositoriesclassUsers<Main::Repo[:users]commands:create,update: :by_pk,delete: :by_pkdefquery(conditions)users.where(conditions)enddefemail_taken?(email)users.exist?(email:email)enddefby_id(id)users.by_pk(id).one!endendendend
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.rbRSpec.shared_context"Rack::Test"do# Define the app for Rack::Test requestslet(:app)doHanami.bootHanami.appendendRSpec.configuredo|config|config.includeRack::Test::Methods,type: :requestconfig.include_context"Rack::Test",type: :requestconfig.includeWarden::Test::Helpers,type: :requestconfig.after(:each,type: :request){Warden.test_reset!}end
# spec/requests/auth_spec.rbRSpec.describe'AuthenticationSpec',:db,type: :requestdocontext'when action inherits from authenticated action'docontext"when user is logged in"dolet!(:user){factory[:user,name:"Guy",email:"my@guy.com"]}it'succeeds'dologin_asuserget"/search/isbn",{isbn:"978-0-306-40615-7"}expect(last_response.status).tobe(200)endendcontext"when there is no user"doit"rejects the request, redirects"doget"/search/isbn",{isbn:"978-0-306-40615-7"}expect(last_response.status).tobe(302)endendendend
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 registrationget'/register',to:'register.new'post"/users",to:"users.create"# Session managementget'/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.describeMain::Actions::Users::Create,:dbdoit"works with the right params"doparams={email:"some@email.com",name:"John Doe",password:"password",password_confirmation:"password"}response=subject.call(params)expect(response).tobe_successfulendcontext"with bad params"dolet(:params){Hash[]}it"fails with missing params"doresponse=subject.call({})expect(response).not_tobe_successfulendendcontext"with password missmatch"dolet(:params){{email:"good@email.com",name:"John Doe",password:"somepassword",password_confirmation:"differentthing"}}it"fails with password missmatch"doresponse=subject.call(params)expect(response).not_tobe_successfulendendcontext"with email already taken"dolet!(: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"doresponse=subject.call(params)expect(response).not_tobe_successfulendendend
Basic tests and here is how we can make them pass.
require'bcrypt'moduleMainmoduleActionsmoduleUsersclassCreate<Main::ActionincludeDeps[users_repo:"repositories.users"]paramsdorequired(:email).filled(:string)required(:name).filled(:string)required(:password).filled(:string)required(:password_confirmation).filled(:string)enddefhandle(request,response)halt422,{errors:request.params.errors}.to_jsonunlessrequest.params.valid?halt422,{errors:"Password must match the confirmation"}.to_jsonunlessrequest.params[:password]==request.params[:password_confirmation]halt422,{errors:"This email is already taken"}.to_jsonifusers_repo.email_taken?(request.params[:email])password_salt=BCrypt::Engine.generate_saltpassword_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)endendendendend
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.
moduleMainmoduleActionsmoduleSessionsclassCreate<Main::ActionincludeDeps[users_repo:"repositories.users"]paramsdorequired(:email).filled(:string)required(:password).filled(:string)enddefhandle(request,response)halt422,{errors:request.params.errors}.to_jsonunlessrequest.params.valid?request.env['warden'].authenticate!user=users_repo.by_email(request.params[:email])ifuser&&user.password_hash==BCrypt::Engine.hash_secret(request.params[:password],user.password_salt)request.session[:user_id]=user.idresponse.redirect"/"elsehalt401,"Unauthorized"endendendendendend
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_userprivatedefauthenticate_user(request,response)response.redirect_to("/login")unlessrequest.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 beforeend
An examplery action, that will make the previous red spec (the one with redirection) green
moduleMainmoduleActionsmoduleIsbnSearchclassShow<Libus::Actions::AuthenticatedActionparamsdorequired(:isbn).filled(:string)enddefhandle(request,response)halt422,{errors:request.params.errors}.to_jsonunlessrequest.params.valid?Main::Workers::IsbnSearch.perform_async(request.params[:isbn])response.render(view,isbn:request.params[:isbn])endendendendend
Log out
Logging out is the simplest part of it all.
In my navbar I have a bit that goes like this:
<%ifwarden.user%><liclass="justify-center"><%=form_for:logout,'logout',method: :deletedo%><buttontype="submit">Logout</button><%end%></li><%else%><liclass="justify-center"><ahref="/login">Login</a></li><%end%>
Which I handle with:
moduleMainmoduleActionsmoduleSessionsclassDestroy<Main::Actiondefhandle(request,response)request.env['warden'].logoutresponse.redirect_to("/")endendendendend
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.
UsingDevise 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 withoutDevise 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 callcurrent_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 fromthis 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.
Top comments(2)

Your article is awesome! thanks a lot <3.
Have a question, in the Sessions #create action you also have the same auth code that in warden.rb #aunthenticate!user.password_hash == BCrypt::Engine.hash_secret
related to validating the pwd. Is that really necessary?
Thanks again

- LocationBiałystok
- EducationLaw degree
- Workbackend (mostly) developer at 2N IT
- Joined
Hey,
No, it kinda is not, I just preferred to copy it in the article for explicitness of how how passwords gets validated safely, but good point, I should have made a note of that.
For further actions, you may consider blocking this person and/orreporting abuse