
Hanami with Rodauth
Security is an important part of web application engineering and a good solution for implementing authentication within your application, both in terms of UI and API, is Rodauth. That said, Rodauth does come with a learning curve. The following will walk you through setting up Rodauth in your Hanami application because, unless you are a security expert, you won’t want to do this yourself.
Table of Contents
-
[Layouts](#_layout…

Hanami with Rodauth
Security is an important part of web application engineering and a good solution for implementing authentication within your application, both in terms of UI and API, is Rodauth. That said, Rodauth does come with a learning curve. The following will walk you through setting up Rodauth in your Hanami application because, unless you are a security expert, you won’t want to do this yourself.
Table of Contents
Setup
To get started, add the following gems to your application:
bundle add bcrypt jwt rodauth
Here’s what each provides:
Bcrypt: Necessary for authentication, passwords, and encryption in general.
JWT: Necessary for API authentication.
Rodauth: Necessary to implement authentication for both UI and API purposes.
We’ll use these gems to leverage the Rodauth features.
Features
While there are plenty of features you can enable, we’ll focus on the following in the order listed as a minimum to get you started:
Base
Active Sessions
Audit Logging
JWT Refresh
Recovery Codes
Only the Base feature is automatic and provided for you by default. The rest you must manually add. You can definitely add or subtract from this list, based on your needs, but this is the feature set we’ll be working through together.
Database
You’ll want to spend the most time thinking through which Rodauth features you want to support and configure. Once you’ve mapped out your desired feature set, it’s helpful to add # Feature: <feature> code comments so you have a direct reference to the feature associated with each table you are creating. This same code comment can be reused when adding the middleware necessary for enabling and configuring this feature set (more on this soon). Here’s the associated database migrations based on the previously discussed feature set:
User Migration
Necessary to authenticate and manage users within the system. This supports multiple Rodauth features. A code comment denotes each enabled feature.
ROM::SQL.migration do
up do
extension :date_arithmetic
deadline = lambda do |days|
{null: false, default: Sequel.date_add(Sequel::CURRENT_TIMESTAMP, days:)}
end
# Feature (automatic): base
create_table :user_status do
primary_key :id
column :name, String, unique: true, null: false
end
from(:user_status).import(%i[id name], [[1, "Unverified"], [2, "Verified"], [3, "Closed"]])
create_table :user do
primary_key :id, type: :Bignum
foreign_key :status_id, :user_status, null: false, default: 1
column :name, String
citext :email, null: false
column :settings, :jsonb, null: false, default: "{}"
column :created_at, :timestamp, null: false, default: Sequel::CURRENT_TIMESTAMP
column :updated_at, :timestamp, null: false, default: Sequel::CURRENT_TIMESTAMP
constraint :valid_email, email: /^[^,;@ \r\n]+@[^,@; \r\n]+\.[^,@; \r\n]+$/
index :email, unique: true, where: {status_id: [1, 2]}
index :settings, type: :gin
end
# Feature: active_sessions
create_table :user_active_session_key do
foreign_key :user_id, :user, type: :Bignum
column :session_id, String
column :created_at, Time, null: false, default: Sequel::CURRENT_TIMESTAMP
column :last_use, Time, null: false, default: Sequel::CURRENT_TIMESTAMP
primary_key %i[user_id session_id]
end
# Feature: audit_logging
create_table :user_authentication_audit_log do
primary_key :id, type: :Bignum
foreign_key :user_id, :user, null: false, type: :Bignum
column :at, DateTime, null: false, default: Sequel::CURRENT_TIMESTAMP
column :message, String, null: false
column :metadata, :jsonb
index %i[user_id at], name: :audit_user_at_idx
index :at, name: :audit_at_idx
end
# Feature: jwt_refresh
create_table :user_jwt_refresh_key do
primary_key :id, type: :Bignum
foreign_key :user_id, :user, null: false, type: :Bignum
column :key, String, null: false
column :deadline, DateTime, deadline.call(1)
index :user_id, name: :user_jwt_refresh_key_user_id_idx
end
# Feature: recovery_codes
create_table :user_recovery_code do
foreign_key :id, :user, type: :Bignum
column :code, String
primary_key %i[id code]
end
# Feature: remember
create_table :user_remember_key do
foreign_key :id, :user, primary_key: true, type: :Bignum
column :key, String, null: false
column :deadline, DateTime, deadline.call(14)
end
run %(GRANT REFERENCES ON "user" TO CURRENT_USER)
run "GRANT SELECT, INSERT, UPDATE, DELETE ON user_status TO CURRENT_USER"
run %(GRANT SELECT, INSERT, UPDATE, DELETE ON "user" TO CURRENT_USER)
run "GRANT SELECT, INSERT, UPDATE, DELETE ON user_active_session_key TO CURRENT_USER"
run "GRANT SELECT, INSERT, UPDATE, DELETE ON user_authentication_audit_log TO CURRENT_USER"
run "GRANT SELECT, INSERT, UPDATE, DELETE ON user_jwt_refresh_key TO CURRENT_USER"
run "GRANT SELECT, INSERT, UPDATE, DELETE ON user_recovery_code TO CURRENT_USER"
run "GRANT SELECT, INSERT, UPDATE, DELETE ON user_remember_key TO CURRENT_USER"
end
down do
drop_table :user_remember_key,
:user_recovery_code,
:user_jwt_refresh_key,
:user_authentication_audit_log,
:user_active_session_key,
:user_status,
:user
end
end
User Hash Migration
Necessary to capture user password hashes. This is done as a separate table with different permissions in order to improve security as documented here.
require "rodauth/migrations"
ROM::SQL.migration do
settings = {table_name: :user_password_hash}
up do
# Feature (automatic): base
create_table :user_password_hash do
foreign_key :id, :user, primary_key: true, type: :Bignum
column :password_hash, String, null: false
end
Rodauth.create_database_authentication_functions self, **settings
run "REVOKE ALL ON user_password_hash FROM public"
run "REVOKE ALL ON FUNCTION rodauth_get_salt(int8) FROM public"
run "REVOKE ALL ON FUNCTION rodauth_valid_password_hash(int8, text) FROM public"
run "GRANT INSERT, UPDATE, DELETE ON user_password_hash TO CURRENT_USER"
run "GRANT SELECT(id) ON user_password_hash TO CURRENT_USER"
run "GRANT EXECUTE ON FUNCTION rodauth_get_salt(int8) TO CURRENT_USER"
run "GRANT EXECUTE ON FUNCTION rodauth_valid_password_hash(int8, text) TO CURRENT_USER"
end
down { Rodauth.drop_database_authentication_functions self, **settings }
end
One of the most important aspects of the above code is that all tables are prefixed as user tables. This is because, by default, Rodauth assumes account prefixes. The reason for making these user based tables instead of account tables is because this allows you to scale to more sophisticated user/account management in terms of level of access. For example, this opens up the following possibilities:
Level 1 (users): Basic user login/logout and resource access.
Level 2 (accounts): The unit of ownership in terms of system resources for which a user can belong to an account for access.
Level 3 (memberships): The many to many relationship in which a user can belong to multiple accounts and an account can have multiple users.
Level 4 (roles): The roles (and permissions) associated with the account.
Level 5 (organizations): The ultimate super account (enterprise level) in which you group multiple accounts under a single organization.
For the purposes of this discussion, we’ll only focus on Level 1.
Slice
Due to Rodauth needing middleware; feature configuration; and multiple templates, it’s a good idea to encapsulate all of this functionality within an authentication slice. Here’s what this looks like structurally:
slices/authentication
├── assets
│ ├── css
│ │ └── portal.css
│ └── js
│ └── app.js
├── db
│ ├── relation.rb
│ ├── repository.rb
│ └── struct.rb
├── templates
│ ├── layouts
│ │ └── app.html.erb
│ ├── login.html.erb
│ ├── login_update.html.erb
│ ├── logout.html.erb
│ ├── password_update.html.erb
│ ├── register.html.erb
│ └── remember.html.erb
├── views
│ └── context.rb
├── feature.rb
├── middleware.rb
└── view.rb
Some of the above is boilerplate when generating a slice. That said, the following sections will detail the key files and ignore the boilerplate code.
Feature
Rodauth supports the ability to define custom features. In this case, we need a custom feature specific for bridging the gap between Hanami and Rodauth when we wire up our middleware (more on this shortly). In other words, a translation layer. In our case, we are swapping out Rodauth’s view rendering with Hanami’s view rendering but only if one of our Hanami templates exist. Otherwise, we fall back to Rodauth. Here’s what this looks like:
# slices/authentication/feature.rb
# auto_register: false
require "rodauth"
Rodauth::Feature.define :hanami do
auth_value_method :hanami_view, nil
def view(name, *)
layout_path = view_base.class.layout_path view_base.config.layout
scope = view_rendering.scope rodauth: self
view_rendering.template(layout_path, scope) { render name }
end
def render name
return super unless view_template? name
view_rendering.template name, view_rendering.scope(rodauth: self)
end
private
def view_template? name
view_rendering.renderer.__send__ :lookup, name, view_base.config.default_format
end
def view_rendering
@view_rendering ||= view_base.rendering format: view_base.config.default_format,
context: view_context
end
def view_context
@view_context ||= begin
action_request = Hanami::Action::Request.new(
env: request.env,
params: request.params,
session_enabled: true
)
view_base.config.default_context.class.new request: action_request
end
end
def view_base
@view_base ||= hanami_view.call
end
end
Middleware
The middleware layer allows you to inherit Rodauth functionality and configure behavior as Rack middleware for which you can wire into your application routes. Remember when we added the hanami feature above? Well, this is where you enable that feature along with all of the features provided by Rodauth. Here’s the implementation:
Implementation
# slices/authentication/middleware.rb
# auto_register: false
require "refinements/string"
require "roda"
require "rodauth"
require_relative "feature"
module Authentication
# Specialized Roda middleware for authentication.
class Middleware < Roda
using Refinements::String
plugin :middleware
plugin :rodauth, json: true do
enable :active_sessions,
:audit_logging,
:change_login,
:change_password,
:create_account,
:disallow_common_passwords,
:hanami,
:jwt_refresh,
:login,
:logout,
:remember,
:recovery_codes
db Authentication::Slice["db.gateway"].connection
# Feature (automatic): base
accounts_table :user
after_login { remember_login }
already_logged_in { redirect "/" }
flash_error_key :alert
hmac_secret Hanami.app[:settings].app_secret
login_label "Email"
password_hash_table :user_password_hash
require_login_error_flash "Please log in to continue."
template_opts layout: nil
unverified_account_message "Unverified user, please verify before logging in."
# Feature (automatic): login_password_requirements_base
require_password_confirmation? false
# Feature: active_sessions
active_sessions_account_id_column :user_id
active_sessions_table :user_active_session_key
# Feature: audit_logging
audit_logging_table :user_authentication_audit_log
audit_logging_account_id_column :user_id
# Feature: change_login
change_login_route "me/login"
change_login_view { view "login_update", nil }
# Feature: change_password
change_password_route "me/password"
change_password_view { view "password_update", nil }
change_password_button "Save"
# Feature: create_account
create_account_button "Create"
create_account_link_text "Register."
create_account_route "register"
create_account_view { view "register", nil }
change_login_button "Save"
after_create_account do
user_id = account[:id]
user_name = param "name"
account_id = db[:account].insert label: user_name, name: user_name.snakecase
db[:user].where(id: user_id).update name: user_name
db[:membership].insert user_id: user_id, account_id:
end
# Feature (custom): hanami
hanami_view(proc { View.new })
# Feature: jwt
jwt_secret Hanami.app[:settings].app_secret
jwt_refresh_route "api/jwt"
# Feature: jwt_refresh
jwt_access_token_not_before_period Hanami.app[:settings].api_access_token_period
jwt_refresh_token_account_id_column :user_id
jwt_refresh_token_table :user_jwt_refresh_key
# Feature: login
login_error_flash "There was an error signing in."
login_form_footer_links_heading { nil }
login_notice_flash "You have been logged in."
login_return_to_requested_location? true
multi_phase_login_view { view "login_multi_phase", nil }
# Feature: logout
logout_notice_flash "You have been logged out."
logout_redirect "/"
# Feature: remember
remember_button "Save"
remember_table :user_remember_key
remember_route "me/remember"
# Feature: recovery_codes
recovery_codes_table :user_recovery_code
end
route do |request|
env["rodauth"] = rodauth
request.rodauth
end
end
end
The above might seem daunting, at first, but is mostly configuration logic. You can read it from top to bottom as follows:
Define the middleware plugin because we definitely want Rodauth to behave like middleware so we can wire into our routes.
Define the rodauth plugin and then enable all of the features you want (this includes our custom hanami feature).
Configure each feature. Once again, there is a code comment that helps you jump to the associated Rodauth documentation of that feature.
Definitely spend time reading through Rodauth’s feature documentation to customize further as you can adjust for your needs.
View Context
In order to not repeat yourself and leverage common styles from your main application, you’ll want to include your application’s main assets. This can be done with this view:
# authentication/views/context.rb
# auto_register: false
module Authentication
module Views
# The slice view context.
class Context < Hanami::View::Context
include Deps[main_assets: "main.assets"]
end
end
end
The above will allow you to use your application’s assets (mostly styles) within all templates associated with this slice.
Routes
Necessary to ensure all routes are properly authenticated by using the new Rodauth middleware at the top of our routes.
module Demos
# The application routes.
class Routes < Hanami::Routes
slice(:authentication, at: "/") { use Authentication::Middleware }
end
end
Layouts
While you can get by with Rodauth’s default layouts and templates, you’ll want to customize further. The following documents the layouts and templates used to customize the UI. These are listed by pure file structure only since providing source code for each would be slightly overkill. That said, there is a process for customization which looks like this:
Open the Rodauth gem in your default text editor. If using Gemsmith, this is as simple as running: gemsmith --edit rodauth.
Once in your editor, open the templates folder where you’ll see multiple *.str files. These, roughly, map to each feature you enabled and configured earlier. You can then take the source code and convert to ERB syntax that is compatible with Hanami Views.
Repeat this process for each file you need to customize further.
Here’s what the file structure looks like after using the above process:
# Layouts
app/templates/layouts/authentication.html.erb
# Templates
slices/authentication/templates/layouts/app.html.erb
slices/authentication/templates/login.html.erb
slices/authentication/templates/login_update.html.erb
slices/authentication/templates/logout.html.erb
slices/authentication/templates/password_update.html.erb
slices/authentication/templates/register.html.erb
slices/authentication/templates/remember.html.erb
In case it helps, here’s an example of the Rodauth logout.str to Hanami logout.html.erb template conversion:
<form method="post" class="rodauth" role="form" id="logout-form">
<%== rodauth.logout_additional_form_tags %>
<%== rodauth.csrf_tag %>
<%== rodauth.button rodauth.logout_button, class: "btn btn-warning" %>
</form>
Actions
With the Rodauth Authentication slice in place, you’ll want to update your root application Action so all subclasses can inherit authentication behavior. This can be done by defining a protected authorize method with a subsequent private handle_rodauth_redirect for wiring in flash and redirect support. Example:
# auto_register: false
require "hanami/action"
module Demo
# The application base action.
class Action < Hanami::Action
before :authorize
protected
def authorize request, response
rodauth = request.env["rodauth"]
return unless rodauth
handle_rodauth_redirect(rodauth, response) { rodauth.require_account }
response[:current_user_id] = rodauth.account_id
end
private
def handle_rodauth_redirect rodauth, response
halted = catch(:halt) { yield }
return unless halted
code, headers, body = *halted
rodauth.flash.next.each { |key, value| response.flash[key] = value }
response.redirect headers["Location"], code
throw :halt, [code, body]
end
end
end
The above works for all UI and API requests. In rare situations where you need to disable the authorize before callback, you can override authorize as follows in your subclass:
# frozen_string_literal: true
module Demo
module Actions
module API
module Log
# The create action.
class Create < Action
# Truncated for brevity...
protected
def authorize(*) = nil
end
end
end
end
end
The above will completely disable the authentication check for you API log request (POST) with minimal effort.
RSpec
Now that everything is wired up, you’ll want to add a couple RSpec shared contexts to aid in writing your feature and request specs. First, here’s the with login shared context you can use for all feature (UI) specs:
# demo/spec/support/shared_contexts/login.rb
RSpec.shared_context "with login" do
let(:user) { Factory[:user, :verified] }
before do
Factory[:user_password_hash, id: user.id]
visit "/login"
fill_in "login", with: user.email
click_button "Login"
fill_in "Password", with: "password"
click_button "Login"
end
end
Second, here’s the with JWT shared context you can use for all request (API) specs:
# demo/spec/support/shared_contexts/jwt.rb
RSpec.shared_context "with JWT" do
let(:user) { Factory[:user, :verified] }
let :access_token do
post "/login",
{login: user.email, password: "password"}.to_json,
{"CONTENT_TYPE" => "application/json"}
JSON[last_response.body, symbolize_names: true].fetch :access_token
end
before { Factory[:user_password_hash, id: user.id] }
end
Feel free to customize a needed but the above will allow you to quickly authenticate, for testing purposes, when writing feature and request specs.
Tools
Due to Rodauth having a rich feature set, it quickly becomes tiresome jumping around the docomentation so, for Alfred fans, you might want to leverage the Pennyworth gem which provides a Rodauth Workflow for quick access to the documentation. Even better you can fuzzy type to quickly jump to any of the supported features.
Conclusion
As mentioned earlier, there is definitely a learning curve when folding Rodauth into your web application but is much faster/better than implementing this yourself. Plus, as you’ve seen, there’s plenty of opportunity to customize for your specific needs.
The core integration happens via the Rodauth feature and corresponding middleware. The rest is getting up to speed with all Rodauth’s feature then leveraging the power of Hanami slices, routes, views, layouts, and templates to customize as desired.
May your application be secure!