Warden of Hanami - hanami.rb basic authentication

krzykamil

Krzysztof

Posted on May 7, 2024

Warden of Hanami - hanami.rb basic authentication

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
Enter fullscreen mode Exit fullscreen mode

Also add a line to settings

# config/settings.rb
setting :session_secret, constructor: Types::String
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
# 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
# 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
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 %>
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.

💖 💪 🙅 🚩
krzykamil
Krzysztof

Posted on May 7, 2024

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related