/rodauth-rails

Rails integration for Rodauth authentication framework

Primary LanguageHTMLMIT LicenseMIT

rodauth-rails

Provides Rails integration for the Rodauth authentication framework.

Resources

🔗 Useful links:

🎥 Screencasts:

📚 Articles:

Why Rodauth?

There are already several popular authentication solutions for Rails (Devise, Sorcery, Clearance, Authlogic), so why would you choose Rodauth? Here are some of the advantages that stand out for me:

Sequel

One common concern for people coming from other Rails authentication frameworks is the fact that Rodauth uses Sequel for database interaction instead of Active Record. For Rails apps using Active Record, rodauth-rails configures Sequel to reuse Active Record's database connection. This makes it run smoothly alongside Active Record, even allowing calling Active Record code from within Rodauth configuration. So, for all intents and purposes, Sequel can be treated just as an implementation detail of Rodauth.

Installation

Add the gem to your project:

$ bundle add rodauth-rails

Next, run the install generator:

$ rails generate rodauth:install

This generator will create a Rodauth app and configuration with common authentication features enabled, a database migration with tables required by those features, a mailer with default templates, and a few other files.

Feel free to remove any features you don't need, along with their corresponding tables. Afterwards, run the migration:

$ rails db:migrate

For your mailer to be able to generate email links, you'll need to set up default URL options in each environment. Here is a possible configuration for config/environments/development.rb:

config.action_mailer.default_url_options = { host: "localhost", port: 3000 }

Install options

The install generator will use the accounts table by default. You can specify a different table name:

$ rails generate rodauth:install users

If you want Rodauth endpoints to be exposed via JSON API:

$ rails generate rodauth:install --json # regular authentication using the Rails session
# or
$ rails generate rodauth:install --jwt # token authentication via the "Authorization" header
$ bundle add jwt

To use Argon2 instead of bcrypt for password hashing:

$ rails generate rodauth:install --argon2
$ bundle add argon2

Usage

The Rodauth app will be called for each request before it reaches the Rails router. It handles requests to Rodauth endpoints, and allows you to call additional code before your main routes.

$ rails middleware
# ...
# use Rodauth::Rails::Middleware (calls your Rodauth app)
# run YourApp::Application.routes

Routes

Because requests to Rodauth endpoints are handled by Roda, Rodauth routes will not show in rails routes. You can use the rodauth:routes rake task to view the list of endpoints based on currently loaded features:

$ rails rodauth:routes
Routes handled by RodauthApp:

  GET|POST  /login                   rodauth.login_path
  GET|POST  /create-account          rodauth.create_account_path
  GET|POST  /verify-account-resend   rodauth.verify_account_resend_path
  GET|POST  /verify-account          rodauth.verify_account_path
  GET|POST  /change-password         rodauth.change_password_path
  GET|POST  /change-login            rodauth.change_login_path
  GET|POST  /logout                  rodauth.logout_path
  GET|POST  /remember                rodauth.remember_path
  GET|POST  /reset-password-request  rodauth.reset_password_request_path
  GET|POST  /reset-password          rodauth.reset_password_path
  GET|POST  /verify-login-change     rodauth.verify_login_change_path
  GET|POST  /close-account           rodauth.close_account_path

Using this information, you can add some basic authentication links to your navigation header:

<% if rodauth.logged_in? %>
  <%= button_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 %>

These routes are fully functional, feel free to visit them and interact with the pages. The templates that ship with Rodauth aim to provide a complete authentication experience, and the forms use Bootstrap markup.

Current account

The Rodauth object defines a #rails_account method, which returns a model instance of the currently logged in account. You can create a helper method for easy access from controllers and views:

class ApplicationController < ActionController::Base
  private

  def current_account
    rodauth.rails_account
  end
  helper_method :current_account # skip if inheriting from ActionController::API
end

Requiring authentication

You can require authentication for routes at the middleware level in in your Rodauth app's routing block, which helps keep the authentication logic encapsulated:

# app/misc/rodauth_app.rb
class RodauthApp < Rodauth::Rails::App
  route do |r|
    r.rodauth # route rodauth requests

    if r.path.start_with?("/dashboard") # /dashboard/* routes
      rodauth.require_account # redirect to login page if not authenticated
    end
  end
end

You can also require authentication at the controller layer:

class ApplicationController < ActionController::Base
  private

  def authenticate
    rodauth.require_account # redirect to login page if not authenticated
  end
end
class DashboardController < ApplicationController
  before_action :authenticate
end

Additionally, routes can be authenticated at the Rails router level:

# config/routes.rb
Rails.application.routes.draw do
  constraints Rodauth::Rails.authenticate do
    # ... these routes will require authentication ...
  end

  constraints Rodauth::Rails.authenticate { |rodauth| rodauth.uses_two_factor_authentication? } do
    # ... these routes will be available only if 2FA is setup ...
  end

  constraints Rodauth::Rails.authenticate(:admin) do
    # ... these routes will be authenticated with secondary "admin" configuration ...
  end

  constraints -> (r) { !r.env["rodauth"].logged_in? } do # or env["rodauth.admin"]
    # ... these routes will be available only if not authenticated ...
  end
end

Controller

Your Rodauth configuration is linked to a Rails controller, which is primarily used to render views and handle CSRF protection, but will also execute any callbacks and rescue handlers defined on it around Rodauth endpoints.

# app/misc/rodauth_main.rb
class RodauthMain < Rodauth::Rails::Auth
  configure do
    rails_controller { RodauthController }
  end
end
class RodauthController < ApplicationController
  before_action :verify_captcha, only: :login, if: -> { request.post? } # executes before Rodauth endpoints
  rescue_from("SomeError") { |exception| ... } # rescues around Rodauth endpoints
end

Various methods are available in your Rodauth configuration to bridge the gap with the controller:

class RodauthMain < Rodauth::Rails::Auth
  configure do
    # calling methods on the controller:
    after_create_account do
      rails_controller_eval { some_controller_method(account_id) }
    end

    # accessing Rails URL helpers:
    login_redirect { rails_routes.dashboard_path }

    # accessing Rails request object:
    after_change_password do
      if rails_request.format.turbo_stream?
        return_response rails_render(turbo_stream: [turbo_stream.replace(...)])
      end
    end

    # accessing Rails cookies:
    after_login { rails_cookies.permanent[:last_account_id] = account_id }
  end
end

Views

The templates built into Rodauth are useful when getting started, but soon you'll want to start editing the markup. You can run the following command to copy Rodauth templates into your Rails app:

$ rails generate rodauth:views

This will generate views for Rodauth features you have currently enabled into the app/views/rodauth directory (provided that RodauthController is set for the main configuration).

The generator accepts various options:

# generate views with Tailwind markup (requires @tailwindcss/forms plugin)
$ rails generate rodauth:views --css=tailwind

# specify Rodauth features to generate views for
$ rails generate rodauth:views login create_account lockout otp

# generate views for all Rodauth features
$ rails generate rodauth:views --all

# specify a different Rodauth configuration
$ rails generate rodauth:views webauthn two_factor_base --name admin

Mailer

The install generator will create RodauthMailer with default email templates, and configure Rodauth features that send emails as part of the authentication flow to use it.

# app/mailers/rodauth_mailer.rb
class RodauthMailer < ApplicationMailer
  def verify_account(account_id, key) ... end
  def reset_password(account_id, key) ... end
  def verify_login_change(account_id, key) ... end
  def password_changed(account_id) ... end
  # def email_auth(account_id, key) ... end
  # def unlock_account(account_id, key) ... end
end
# app/misc/rodauth_main.rb
class RodauthMain < Rodauth::Rails::Auth
  configure do
    create_reset_password_email { RodauthMailer.reset_password(account_id, reset_password_key_value) }
    create_verify_account_email { RodauthMailer.verify_account(account_id, verify_account_key_value) }
    create_verify_login_change_email { |_login| RodauthMailer.verify_login_change(account_id, verify_login_change_key_value) }
    create_password_changed_email { RodauthMailer.password_changed(account_id) }
    # create_email_auth_email { RodauthMailer.email_auth(account_id, email_auth_key_value) }
    # create_unlock_account_email { RodauthMailer.unlock_account(account_id, unlock_account_key_value) }
    send_email do |email|
      # queue email delivery on the mailer after the transaction commits
      db.after_commit { email.deliver_later }
    end
  end
end

This configuration calls #deliver_later, which uses Active Job to deliver emails in a background job. If you want to send emails synchronously, you can modify the configuration to call #deliver_now instead.

If you're using a background processing library without an Active Job adapter, or a 3rd-party service for sending transactional emails, see this wiki page on how to set it up.

Migrations

The install generator will create a migration for tables used by the Rodauth features enabled by default. For any additional features, you can use the migration generator to create the required tables:

$ rails generate rodauth:migration otp sms_codes recovery_codes
# db/migration/*_create_rodauth_otp_sms_codes_recovery_codes.rb
class CreateRodauthOtpSmsCodesRecoveryCodes < ActiveRecord::Migration
  def change
    create_table :account_otp_keys do |t| ... end
    create_table :account_sms_codes do |t| ... end
    create_table :account_recovery_codes do |t| ... end
  end
end

If you're storing account records in a table other than accounts, you'll want to specify the appropriate table prefix when generating new migrations:

$ rails generate rodauth:migration base active_sessions --prefix user

# Add the following to your Rodauth configuration:
#
#   accounts_table :users
#   active_sessions_table :user_active_session_keys
#   active_sessions_account_id_column :user_id
# db/migration/*_create_rodauth_user_base_active_sessions.rb
class CreateRodauthUserBaseActiveSessions < ActiveRecord::Migration
  def change
    create_table :users do |t| ... end
    create_table :user_active_session_keys do |t| ... end
  end
end

You can change the default migration name:

$ rails generate rodauth:migration email_auth --name create_account_email_auth_keys
# db/migration/*_create_account_email_auth_keys.rb
class CreateAccountEmailAuthKeys < ActiveRecord::Migration
  def change
    create_table :account_email_auth_keys do |t| ... end
  end
end

Model

The rodauth-model gem provides a Rodauth::Model mixin that can be included into the account model, which defines a password attribute and associations for tables used by enabled authentication features.

class Account < ActiveRecord::Base # Sequel::Model
  include Rodauth::Rails.model # or `Rodauth::Rails.model(:admin)`
end
# setting password hash
account = Account.create!(email: "user@example.com", password: "secret123")
account.password_hash #=> "$2a$12$k/Ub1I2iomi84RacqY89Hu4.M0vK7klRnRtzorDyvOkVI.hKhkNw."

# clearing password hash
account.password = nil
account.password_hash #=> nil

# associations
account.remember_key #=> #<Account::RememberKey> (record from `account_remember_keys` table)
account.active_session_keys #=> [#<Account::ActiveSessionKey>,...] (records from `account_active_session_keys` table)

Multiple configurations

If you need to handle multiple types of accounts that require different authentication logic, you can create new configurations for them. This is done by creating new Rodauth::Rails::Auth subclasses, and registering them under a name.

# app/misc/rodauth_app.rb
class RodauthApp < Rodauth::Rails::App
  configure RodauthMain          # primary configuration
  configure RodauthAdmin, :admin # secondary configuration

  route do |r|
    r.rodauth         # route primary rodauth requests
    r.rodauth(:admin) # route secondary rodauth requests

    if request.path.start_with?("/admin")
      rodauth(:admin).require_account
    end
  end
end
# app/misc/rodauth_admin.rb
class RodauthAdmin < Rodauth::Rails::Auth
  configure do
    # ... enable features ...
    prefix "/admin"
    session_key_prefix "admin_"
    remember_cookie_key "_admin_remember" # if using remember feature

    # search views in `app/views/admin/rodauth` directory
    rails_controller { Admin::RodauthController }
  end
end
# app/controllers/admin/rodauth_controller.rb
class Admin::RodauthController < ApplicationController
end

Then in your application you can reference the secondary Rodauth instance:

rodauth(:admin).authenticated? # checks "admin_account_id" session value
rodauth(:admin).login_path #=> "/admin/login"

You'll likely want to save the information of which account belongs to which configuration to the database, see this guide on how you can do that. Note that you can also share configuration via inheritance.

Outside of a request

The internal_request and path_class_methods features are supported, with defaults taken from config.action_mailer.default_url_options.

# internal requests
RodauthApp.rodauth.create_account(login: "user@example.com", password: "secret123")
RodauthApp.rodauth(:admin).verify_account(account_login: "admin@example.com")

# path and URL methods
RodauthApp.rodauth.close_account_path #=> "/close-account"
RodauthApp.rodauth(:admin).otp_setup_url #=> "http://localhost:3000/admin/otp-setup"

Calling instance methods

If you need to access Rodauth methods not exposed as internal requests, you can use Rodauth::Rails.rodauth to retrieve the Rodauth instance (this requires enabling the internal_request feature):

account = Account.find_by!(email: "user@example.com")
rodauth = Rodauth::Rails.rodauth(account: account) #=> #<RodauthMain::InternalRequest ...>

rodauth.compute_hmac("token") #=> "TpEJTKfKwqYvIDKWsuZhkhKlhaBXtR1aodskBAflD8U"
rodauth.open_account? #=> true
rodauth.two_factor_authentication_setup? #=> true
rodauth.password_meets_requirements?("foo") #=> false
rodauth.locked_out? #=> false

In addition to the :account option, the Rodauth::Rails.rodauth method accepts any options supported by the internal_request feature.

# main configuration
Rodauth::Rails.rodauth(env: { "HTTP_USER_AGENT" => "programmatic" })
Rodauth::Rails.rodauth(session: { two_factor_auth_setup: true })

# secondary configuration
Rodauth::Rails.rodauth(:admin, params: { "param" => "value" })

Using as a library

Rodauth offers a Rodauth.lib method for when you want to use it as a library (via internal requests), as opposed to having it route requests. This gem provides a Rodauth::Rails.lib counterpart that does the same but with Rails integration:

# skip require on boot to avoid inserting Rodauth middleware
gem "rodauth-rails", require: false
# app/misc/rodauth_main.rb
require "rodauth/rails"
require "sequel/core"

RodauthMain = Rodauth::Rails.lib do
  enable :create_account, :login, :close_account
  db Sequel.postgres(extensions: :activerecord_connection, keep_reference: false)
  # ...
end
RodauthMain.create_account(login: "email@example.com", password: "secret123")
RodauthMain.login(login: "email@example.com", password: "secret123")
RodauthMain.close_account(account_login: "email@example.com")

Testing

For system and integration tests, which run the whole middleware stack, authentication can be exercised normally via HTTP endpoints. For example, given a controller

# app/controllers/articles_controller.rb
class ArticlesController < ApplicationController
  before_action -> { rodauth.require_account }

  def index
    # ...
  end
end

One can write ActionDispatch::IntegrationTest test helpers for login and logout by making requests to the Rodauth endpoints:

# test/controllers/articles_controller_test.rb
class ArticlesControllerTest < ActionDispatch::IntegrationTest
  def login(email, password)
    post "/login", params: { email: email, password: password }
    assert_redirected_to "/"
  end

  def logout
    post "/logout"
    assert_redirected_to "/"
  end

  test "required authentication" do
    get :index

    assert_response 302
    assert_redirected_to "/login"
    assert_equal "Please login to continue", flash[:alert]

    account = Account.create!(email: "user@example.com", password: "secret123", status: "verified")
    login(account.email, "secret123")

    get :index
    assert_response 200

    logout

    get :index
    assert_response 302
    assert_equal "Please login to continue", flash[:alert]
  end
end

For more examples and information about testing with rodauth, see this wiki page about testing.

Configuring

The rails feature rodauth-rails loads provides the following configuration methods:

Name Description
rails_render(**options) Renders the template with given render options.
rails_csrf_tag Hidden field added to Rodauth templates containing the CSRF token.
rails_csrf_param Value of the name attribute for the CSRF tag.
rails_csrf_token Value of the value attribute for the CSRF tag.
rails_check_csrf! Verifies the authenticity token for the current request.
rails_controller_instance Instance of the controller with the request env context.
rails_controller Controller class to use for rendering and CSRF protection.
rails_account_model Model class connected with the accounts table.
class RodauthMain < Rodauth::Rails::Auth
  configure do
    rails_account_model { MyApp::Account }
    rails_controller { MyApp::RodauthController }
  end
end

Manually inserting middleware

You can choose to insert the Rodauth middleware somewhere earlier than in front of the Rails router:

Rodauth::Rails.configure do |config|
  config.middleware = false # disable auto-insertion
end

Rails.configuration.middleware.insert_before AnotherMiddleware, Rodauth::Rails::Middleware

How it works

Rack middleware

The railtie inserts Rodauth::Rails::Middleware at the end of the middleware stack, which is just a wrapper around your Rodauth app.

$ rails middleware
# ...
# use Rodauth::Rails::Middleware
# run MyApp::Application.routes

Roda app

The Rodauth::Rails::App class is a Roda subclass that provides a convenience layer over Rodauth.

Configure block

The configure call is a wrapper around plugin :rodauth. By convention, it receives an auth class and configuration name as positional arguments (which get converted into :auth_class and :name plugin options), a block for anonymous auth classes, and also accepts any additional plugin options.

class RodauthApp < Rodauth::Rails::App
  # named auth class
  configure(RodauthMain)
  configure(RodauthAdmin, :admin)

  # anonymous auth class
  configure { ... }
  configure(:admin) { ... }

  # plugin options
  configure(RodauthMain, json: :only, render: false)
end

Route block

The route block is called for each request, before it reaches the Rails router, and it's yielded the request object.

class RodauthApp < Rodauth::Rails::App
  route do |r|
    # called before each request
  end
end

Rack env

The app sets Rodauth objects for each registered configuration in the Rack env, so that they're accessible downstream by the Rails router, controllers and views:

request.env["rodauth"]       #=> #<RodauthMain>
request.env["rodauth.admin"] #=> #<RodauthAdmin> (if using multiple configurations)

Auth class

The Rodauth::Rails::Auth class is a subclass of Rodauth::Auth, which preloads the rails rodauth feature, sets HMAC secret to Rails' secret key base, and modifies some configuration defaults.

class RodauthMain < Rodauth::Rails::Auth
  configure do
    # authentication configuration
  end
end

Rodauth feature

The rails Rodauth feature loaded by Rodauth::Rails::Auth provides the main part of the Rails integration for Rodauth:

  • uses Action View for template rendering
  • uses Action Dispatch for CSRF protection
  • runs Action Controller callbacks and rescue from blocks around Rodauth requests
  • uses Action Mailer to create and deliver emails
  • uses Action Controller instrumentation around Rodauth requests
  • uses Action Mailer's default URL options when calling Rodauth outside of a request

License

The gem is available as open source under the terms of the MIT License.

Code of Conduct

Everyone interacting in the rodauth-rails project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the code of conduct.