Neat authentication for Hanami

by Paweł Świątkowski
02 May 2017

In new frameworks like Hanami, which recently hit 1.0, there is an ongoing problem with libraries for performing simple tasks. One canonical example is user authentication. For now we have Tachiban, which is work in progress, and a number of articles about rolling your own authentication system or use OAuth.

None of them, however, addresses a problem I personally have with many existing authentication solutions (Devise included). When choosing between password-based authentication and OAuth one, the lib always let’s use one of them or favours one heavily. Examples?

  • Rodauth does not integrate with OAuth at all
  • Devise if generally password-based. Its integration with OAuth is obviously secondary and built upon existing solutions (with some hacking).
  • Clearance had some intention of introducing OAuth (see here), but it was abandoned years ago with suggestion to use separate library for handling OAuth.

Can’t we have both?

This question is easy to answer. We can. The answer is omniauth-identity, which basically turns your application database into Yet Another OAuth Provider, letting you have consistent OAuth experience, no matter which method you use.

As awesome as it sounds, a word of warning has to be given: this only handles signing in itself. No registration, no confirmation emails, no account locking – you have to do it all by yourself. But my personal opinion is that you probably be better off doing it manually anyway. Generic solutions like Device are great to bootstrap your project, but they become quite a pain later, when you want to customize it.

Now, can we have ominauth-identity in Hanami? Unfortunately no, as it relies heavily on active record pattern. Therefore I have written Hanami provider for OmniAuth and I’m going to show you how to get it to work in your Hanami app.

The solution is heavily based on Monterail’s tutorial (which is currently out-of-date).

Database

We start with database. What I want is to have a User entity separate from it’s credentials, so that I don’t need to create passwords etc. every time I create a user in tests. I do it by creating two repositories:

Hanami::Model.migration do
  change do
    create_table :users do
      primary_key :id

      column :name, String, null: false
      column :email, String, null: false

      column :created_at, DateTime, null: false
      column :updated_at, DateTime, null: false
    end
  end
end

# and

Hanami::Model.migration do
  change do
    create_table :credentials do
      primary_key :id
      foreign_key :user_id, :users, on_delete: :cascade, null: false

      column :provider, String, null: false
      column :crypted_password, String
      column :external_id, String

      column :created_at, DateTime, null: false
      column :updated_at, DateTime, null: false
    end
  end
end

In this example, credentials table stores information about how the authentication data should be mapped to user. It is kind of interleaved and you might want to separate password-based credentials from OAuth-based ones, if you are SRP fanatic, but here we are going to have just one table for both.

Setup OAuth

Let’s start with setting up authentication with Github OAuth. Refer to Monterail’s tutorial for further explanations. Add this to Gemfile:

gem 'omniauth-github'
gem 'warden'

Then to application.rb:

middleware.use OmniAuth::Builder do
  provider :github, ENV['GH_CLIENT_ID'], ENV['GH_CLIENT_SECRET'], scope: 'user:email'
end

And adjust your .env.development:

GH_CLIENT_ID=xxxx
GH_CLIENT_SECRET=yyyy

web/config/routes.rb:

get '/auth/:provider/callback', to: 'session#create'

Now we should create session#create controller.

module Web::Controllers::Session
  class Create
    include Web::Action

    def auth_hash
      request.env["omniauth.auth"]
    end

    def call(params)
      user = UserRepository.new.auth!(auth_hash)
      warden.set_user user
      redirect_to '/'
    end

    def warden
      request.env['warden']
    end
  end
end

and in UserRepository

class UserRepository < Hanami::Repository
  associations do
    has_many :credentials
  end

  def create_with_credentials(data)
    assoc(:credentials).create(data)
  end

  def auth!(auth_hash)
    info = auth_hash[:info]
    external_id = auth_hash[:uid]
    provider = auth_hash[:provider]
    attrs = {
     name:   info[:name],
     email:  info[:email],
    }

    user = aggregate(:credentials).join(:credentials).
            where(external_id: external_id, provider: provider).
            one

    if user
      update(user.id, attrs)
    else
      create_with_credentials(attrs.merge(
        credentials: [{external_id: external_id, provider: provider}]
      ))
    end
  end
end

We are more or less done here. If a user gets redirected to /auth/github endpoint, their should be asked by Github to authorize your app and logged in. Appropriate records for credentials and users tables should be created. Let’s move to another step then.

Add identity provider

We are going to start with Gemfile again:

gem 'scrypt'
gem 'omniauth-hanami', github: 'katafrakt/omniauth-hanami'

In this example I use scrypt, but feel completely free to use whatever you like. You will see that control over hashing algorithm stays always in your application and the gem does not assume anything about it (unlike Tachiban).

Now, edit application.rb section:

middleware.use OmniAuth::Builder do
  provider :hanami, repository: UserRepository
  provider :github, ENV['GH_CLIENT_ID'], ENV['GH_CLIENT_SECRET'], scope: 'user:email'
end

omniauth-hanami assumes the repository defines find_by_credentials method. You can change it by passing appropriate options to the provider configuration, but let’s define it according to convention.

def find_by_credentials(user_params, password)
  conditions = { email: user_params[:auth_key], credentials__provider: 'self' }
  user = aggregate(:credentials).join(:credentials).where(conditions).as(User).one
  if user && user.credentials.first && SCrypt::Password.new(user.credentials.first.crypted_password) == password
    user
  else
    nil
  end
end

Here is a part about hashing algorithm of choice I mentioned. As you see, checking with SHA-512 or bcrypt would be equally easy. It’s all up to you.

Also, note a convention here. When using email + password combination to sign in, provider column in credentials is set to self. Of course, you might use anything you want. Just make sure to set it correctly while registering a user. In case of my application registration looks like this:

# web/controllers/users/register.rb
require 'scrypt'

module Web::Controllers::Users
  class Register
    include Web::Action

    def call(params)
      user_params = params[:user]
      password = SCrypt::Password.create(user_params[:password])

      repo = UserRepository.new
      repo.create_with_credentials(
        email: user_params[:email],
        name: user_params[:name],
        credentials: [{ crypted_password: password, provider: 'self' }]
      )

      redirect_to '/'
    end
  end
end

Finishing up

To have it working in full, we need a few details. For example, a sign in form:

module Web::Views::Users
  class SignIn
    include Web::View

    def form
      form_for :user, '/auth/hanami/callback' do
        div do
            label :email
            text_field :email
        end
        div do
          label :password
          password_field :password
        end
        div do
          submit 'Sign in!'
        end
      end
    end
  end
end

It contains a hack I don’t really like – sending a POST request to OAuth’s callback URL. However, it is perhaps the easiest way to maintain security of a login. If you have a better idea, let me know. Anyway, to make it work you need to add this to routes.rb:

post '/auth/:provider/callback', to: 'session#create'

Want to see a complete example?

There is a working example here, just copy .env.example to .env.development and fill it with your Github credentials.

end of the article

Tags: hanami ruby dsp

This article was written by me – Paweł Świątkowski – on 02 May 2017. I'm on Fediverse (Ruby-flavoured account, Elixir-flavoured account). Let's talk.