Rails authentication with Rodauth, an elegant Ruby gem
0. Motivation
At BootrAils, until recently, we were uncomfortable about what could be a decent default authentication in any new Rails app - until Rodauth appeared under the radar.
There are no “Active Auth” in the Ruby-on-Rails world, which means if you want to add authentication in your app, you have to rely on a gem - or build it yourself.
For those who already know this field, this is an endless debate over the wild Internet. Devise is the most used gem. However, it always comes with “so-so” appreciations by long-term users : Devise is not so great for corner cases (handling JWT authentication is one of the complaints, amongst many others). Clearance, Sorcery are well-known alternatives, but they are also tightly coupled with Rails itself, and are not-so-easy to tweak when necessary.
1. Enters Rodauth
Rodauth removes most of the pains described above. Rodauth is initially not bound to Rails (it’s a Ruby library). It comes with the following features :
- Login
- Logout
- Change Password
- Change Login
- Reset Password
- Create Account
- Close Account
- Verify Account
- Confirm Password
- Remember (Autologin via token)
- Lockout (Bruteforce protection)
- Audit Logging
- Email Authentication (Passwordless login via email link)
- WebAuthn (Multifactor authentication via WebAuthn)
- WebAuthn Login (Passwordless login via WebAuthn)
- WebAuthn Verify Account (Passwordless WebAuthn Setup)
- OTP (Multifactor authentication via TOTP)
- Recovery Codes (Multifactor authentication via backup codes)
- SMS Codes (Multifactor authentication via SMS)
- Verify Login Change (Verify new login before changing login)
- Verify Account Grace Period (Don’t require verification before login)
- Password Grace Period (Don’t require password entry if recently entered)
- Password Complexity (More sophisticated checks)
- Password Pepper
- Disallow Password Reuse
- Disallow Common Passwords
- Password Expiration
- Account Expiration
- Session Expiration
- Active Sessions (Prevent session reuse after logout, allow logout of all sessions)
- Single Session (Only one active session per account)
- JSON (JSON API support for all other features)
- JWT (JSON Web Token support for all other features)
- JWT Refresh (Access & Refresh Token)
- JWT CORS (Cross-Origin Resource Sharing)
- Update Password Hash (when hash cost changes)
- Argon2
- HTTP Basic Auth
- Change Password Notify
- Internal Request
- Path Class Methods
Not bad for a start ! Chances you need anything else for a standard business are close to zero percent.
No need to say we won’t cover each of the features, but knowing we won’t miss anything is always great !
2. Try it, from scratch
First ensure you have all the classic already installed on your computer :
1
2
3
4
5
6
7
8
9
10
$> ruby -v
ruby 3.0.0p0 // you need at least version 3 here
$> bundle -v
Bundler version 2.2.11
$> npm -v
8.3.0 // you need at least version 7.1 here
$> yarn -v
1.22.10
$> psql --version
psql (PostgreSQL) 13.1 // let's use a production-ready database locally
Any upper version should work
And install a fresh new rails application from the start :
1
2
3
4
5
6
mkdir myapp && cd myapp
echo "source 'https://rubygems.org'" > Gemfile
echo "gem 'rails', '7.0.0'" >> Gemfile
bundle install
bundle exec rails new . --force --css=bootstrap -d=postgresql
bundle update
Bootstrap will allow us a more beautiful demo. Or at least more readable :)
Inside myapp folder, continue with the following terminal commands :
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
# Create a default controller
echo "class HomeController < ApplicationController" > app/controllers/home_controller.rb
echo "end" >> app/controllers/home_controller.rb
# Create another controller (the one that should not be reached without proper authentication)
echo "class OtherController < ApplicationController" > app/controllers/other_controller.rb
echo "end" >> app/controllers/other_controller.rb
# Create routes
echo "Rails.application.routes.draw do" > config/routes.rb
echo ' get "home/index"' >> config/routes.rb
echo ' get "other/index"' >> config/routes.rb
echo ' root to: "home#index"' >> config/routes.rb
echo 'end' >> config/routes.rb
# Create a default view
mkdir app/views/home
echo '<h1>This is home</h1>' > app/views/home/index.html.erb
echo '<div class="lead my-3"><%= link_to "go to other page", other_index_path %></div>' >> app/views/home/index.html.erb
# Create another view (will be also protected by authentication)
mkdir app/views/other
echo '<h1>This is another page</h1>' > app/views/other/index.html.erb
echo '<div class="lead my-3"><%= link_to "go to home page", root_path %></div>' >> app/views/other/index.html.erb
# Create database and schema.rb
bin/rails db:create
bin/rails db:migrate
Good ! We now have a good default Rails 7 application, with a home page, and the “other” page that should be protected from unauthenticated access.
Have a sneak peek of the current app by running
1
./bin/dev
And open http://localhost:3000
Navigate from one page to another. So far nothing incredible, but at least we are ready to try a good authentication gem !
3. Install rodauth-rails
Now open your Gemfile and add
1
gem "rodauth-rails"
and then
1
$/myapp> bundle install
Let’s see what it is about :
1
2
3
4
5
$/myapp> bundle info rodauth-rails
* rodauth-rails (0.18.1)
Summary: Provides Rails integration for Rodauth.
Homepage: https://github.com/janko/rodauth-rails
Path: /Users/shino/.rbenv/versions/3.0.0/lib/ruby/gems/3.0.0/gems/rodauth-rails-0.18.1
Great ! Be prepared for next level :)
4. Install rodauth in your app
The gem is now available, but not the necessary files and folders to run rodauth in your Rails app.
Let’s do it :
1
2
3
4
5
6
7
8
9
10
11
12
13
14
$/myapp> bin/rails generate rodauth:install
create db/migrate/20211224143551_create_rodauth.rb
create config/initializers/rodauth.rb
create config/initializers/sequel.rb
create app/lib/rodauth_app.rb
create app/controllers/rodauth_controller.rb
create app/models/account.rb
create app/mailers/rodauth_mailer.rb
create app/views/rodauth_mailer/email_auth.text.erb
create app/views/rodauth_mailer/password_changed.text.erb
create app/views/rodauth_mailer/reset_password.text.erb
create app/views/rodauth_mailer/unlock_account.text.erb
create app/views/rodauth_mailer/verify_account.text.erb
create app/views/rodauth_mailer/verify_login_change.text.erb
Take a sneak peek of each file in your favorite IDE.
Then type :
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
$/myapp> bin/rails db:migrate
== 20211224143551 CreateRodauth: migrating ====================================
-- enable_extension("citext")
-> 0.1350s
-- create_table(:accounts)
-> 0.0084s
-- create_table(:account_password_hashes)
-> 0.0066s
-- create_table(:account_password_reset_keys)
-> 0.0081s
-- create_table(:account_verification_keys)
-> 0.0217s
-- create_table(:account_login_change_keys)
-> 0.0080s
-- create_table(:account_remember_keys)
-> 0.0050s
== 20211224143551 CreateRodauth: migrated (0.1933s) ===========================
Now the schema.rb looks like this :
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
ActiveRecord::Schema.define(version: 2021_12_24_143551) do
enable_extension "citext"
enable_extension "plpgsql"
create_table "account_login_change_keys", force: :cascade do |t|
t.string "key", null: false
t.string "login", null: false
t.datetime "deadline", precision: 6, null: false
end
create_table "account_password_hashes", force: :cascade do |t|
t.string "password_hash", null: false
end
create_table "account_password_reset_keys", force: :cascade do |t|
t.string "key", null: false
t.datetime "deadline", precision: 6, null: false
t.datetime "email_last_sent", precision: 6, default: -> { "CURRENT_TIMESTAMP" }, null: false
end
create_table "account_remember_keys", force: :cascade do |t|
t.string "key", null: false
t.datetime "deadline", precision: 6, null: false
end
create_table "account_verification_keys", force: :cascade do |t|
t.string "key", null: false
t.datetime "requested_at", precision: 6, default: -> { "CURRENT_TIMESTAMP" }, null: false
t.datetime "email_last_sent", precision: 6, default: -> { "CURRENT_TIMESTAMP" }, null: false
end
create_table "accounts", force: :cascade do |t|
t.citext "email", null: false
t.string "status", default: "unverified", null: false
t.index ["email"], name: "index_accounts_on_email", unique: true, where: "((status)::text = ANY ((ARRAY['unverified'::character varying, 'verified'::character varying])::text[]))"
end
add_foreign_key "account_login_change_keys", "accounts", column: "id"
add_foreign_key "account_password_hashes", "accounts", column: "id"
add_foreign_key "account_password_reset_keys", "accounts", column: "id"
add_foreign_key "account_remember_keys", "accounts", column: "id"
add_foreign_key "account_verification_keys", "accounts", column: "id"
end
4. See available routes
The Rodauth middleware will handle requests (and not the Rails app), thus, routes won’t be shown at /rails/info/routes.
From the docs, here are the available endpoints :
1
2
3
4
5
6
7
8
9
10
11
12
13
14
Routes handled by RodauthApp:
/login rodauth.login_path
/create-account rodauth.create_account_path
/verify-account-resend rodauth.verify_account_resend_path
/verify-account rodauth.verify_account_path
/change-password rodauth.change_password_path
/change-login rodauth.change_login_path
/logout rodauth.logout_path
/remember rodauth.remember_path
/reset-password-request rodauth.reset_password_request_path
/reset-password rodauth.reset_password_path
/verify-login-change rodauth.verify_login_change_path
/close-account rodauth.close_account_path
5. Creating views and UX
You have some templates already available for free, if you want to see how things work. For a tutorial, this is a perfect starting point, so let’s type :
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
$/myapp> bin/rails generate rodauth:views
create app/views/rodauth/_login_form.html.erb
create app/views/rodauth/_login_form_footer.html.erb
create app/views/rodauth/_login_form_header.html.erb
create app/views/rodauth/login.html.erb
create app/views/rodauth/multi_phase_login.html.erb
create app/views/rodauth/create_account.html.erb
create app/views/rodauth/verify_account_resend.html.erb
create app/views/rodauth/verify_account.html.erb
create app/views/rodauth/logout.html.erb
create app/views/rodauth/remember.html.erb
create app/views/rodauth/reset_password_request.html.erb
create app/views/rodauth/reset_password.html.erb
create app/views/rodauth/change_password.html.erb
create app/views/rodauth/change_login.html.erb
create app/views/rodauth/verify_login_change.html.erb
create app/views/rodauth/close_account.html.erb
6. Modifying home page
Now modify the home page, you’ll be then able to play with your app :
1
2
3
4
5
6
7
8
9
10
<h1>This is home</h1>
<div class="lead my-3"><%= link_to "go to other page", other_index_path %></div>
<% if rodauth.logged_in? %>
<%= link_to "Sign out", rodauth.logout_path, method: :post %>
<% else %>
<%= link_to "Sign in", rodauth.login_path %>
<%= link_to "Sign up", rodauth.create_account_path %>
<% end %>
Now launch your local server, and try to create a new account, log out, then log in, the above markup should work properly.
If you want to try the “reset password” feature locally, don’t forget to add the following line to config/environments/development.rb
1
config.action_mailer.default_url_options = { host: 'localhost', port: 3000 }
7. Protecting the other page
Remember we have 2 pages in our app : “home” and “other” (you can reach the other page at http://localhost:3000/other/index)
Modify config/routes.rb as follow :
1
2
3
4
5
6
7
8
# inside config/routes.rb
Rails.application.routes.draw do
get "home/index"
constraints Rodauth::Rails.authenticated do
get "other/index"
end
root to: "home#index"
end
Relaunch your local web server. What happen if once on home, you try to access to the other page by clicking the link ?
8. Docs, credits
Official repository of rodauth-rails is here Official repository of rodauth is here Documentation is here
Thanks a lot to @janko and @jeremyevans for their incredible work, and kind answers to issues and PR on GitHub.
Enjoy !