0.07
The project is in a healthy, maintained state
Provides Rails integration for Rodauth.
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
 Project Readme

rodauth-rails

Provides Rails integration for the Rodauth authentication framework.

Resources

Useful links:

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:

One commmon concern is the fact that, unlike most other authentication frameworks for Rails, Rodauth uses Sequel for database interaction instead of Active Record. There are good reasons for this, and to make Rodauth work smoothly alongside Active Record, rodauth-rails configures Sequel to reuse Active Record's database connection.

Upgrading

Upgrading to 0.7.0

Starting from version 0.7.0, rodauth-rails now correctly detects Rails application's secret_key_base when setting default hmac_secret, including when it's set via credentials or $SECRET_KEY_BASE environment variable. This means that your authentication will now be more secure by default, and Rodauth features that require hmac_secret should now work automatically as well.

However, if you've already been using rodauth-rails in production, where the secret_key_base is set via credentials or environment variable and hmac_secret was not explicitly set, the fact that your authentication will now start using HMACs has backwards compatibility considerations. See the Rodauth documentation for instructions on how to safely transition, or just set hmac_secret nil in your Rodauth configuration.

Installation

Add the gem to your Gemfile:

gem "rodauth-rails", "~> 0.10"

# gem "jwt",      require: false # for JWT feature
# gem "rotp",     require: false # for OTP feature
# gem "rqrcode",  require: false # for OTP feature
# gem "webauthn", require: false # for WebAuthn feature

Then run bundle install.

Next, run the install generator:

$ rails generate rodauth:install

Or 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

The generator will create the following files:

  • Rodauth migration at db/migrate/*_create_rodauth.rb
  • Rodauth initializer at config/initializers/rodauth.rb
  • Sequel initializer at config/initializers/sequel.rb for ActiveRecord integration
  • Rodauth app at app/lib/rodauth_app.rb
  • Rodauth controller at app/controllers/rodauth_controller.rb
  • Account model at app/models/account.rb
  • Rodauth mailer at app/mailers/rodauth_mailer.rb with views

Migration

The migration file creates tables required by Rodauth. You're encouraged to review the migration, and modify it to only create tables for features you intend to use.

# db/migrate/*_create_rodauth.rb
class CreateRodauth < ActiveRecord::Migration
  def change
    create_table :accounts do |t| ... end
    create_table :account_password_hashes do |t| ... end
    create_table :account_password_reset_keys do |t| ... end
    create_table :account_verification_keys do |t| ... end
    create_table :account_login_change_keys do |t| ... end
    create_table :account_remember_keys do |t| ... end
  end
end

Once you're done, you can run the migration:

$ rails db:migrate

Rodauth initializer

The Rodauth initializer assigns the constant for your Rodauth app, which will be called by the Rack middleware that's added in front of your Rails router.

# config/initializers/rodauth.rb
Rodauth::Rails.configure do |config|
  config.app = "RodauthApp"
end

Sequel initializer

Rodauth uses Sequel for database interaction. If you're using ActiveRecord, an additional initializer will be created which configures Sequel to use the ActiveRecord connection.

# config/initializers/sequel.rb
require "sequel/core"

# initialize Sequel and have it reuse Active Record's database connection
DB = Sequel.connect("postgresql://", extensions: :activerecord_connection)

Rodauth app

Your Rodauth app is created in the app/lib/ directory, and comes with a default set of authentication features enabled, as well as extensive examples on ways you can configure authentication behaviour.

# app/lib/rodauth_app.rb
class RodauthApp < Rodauth::Rails::App
  configure do
    # authentication configuration
  end

  route do |r|
    # request handling
  end
end

Controller

Your Rodauth app will by default use RodauthController for view rendering, CSRF protection, and running controller callbacks and rescue handlers around Rodauth actions.

# app/controllers/rodauth_controller.rb
class RodauthController < ApplicationController
end

Account model

Rodauth stores user accounts in the accounts table, so the generator will also create an Account model for custom use.

# app/models/account.rb
class Account < ApplicationRecord
end

Rodauth mailer

The default Rodauth app is configured to use RodauthMailer mailer for sending authentication emails.

# app/mailers/rodauth_mailer.rb
class RodauthMailer < ApplicationMailer
  def verify_account(recipient, email_link) ... end
  def reset_password(recipient, email_link) ... end
  def verify_login_change(recipient, old_login, new_login, email_link) ... end
  def password_changed(recipient) ... end
  # def email_auth(recipient, email_link) ... end
  # def unlock_account(recipient, email_link) ... end
end

Usage

Routes

We can see the list of routes our Rodauth middleware handles:

$ rails rodauth:routes
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

Using this information, we could add some basic authentication links to our navigation header:

<% 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 %>

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

To be able to fetch currently authenticated account, let's define a #current_account method that fetches the account id from session and retrieves the corresponding account record:

# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
  before_action :current_account, if: -> { rodauth.logged_in? }

  private

  def current_account
    @current_account ||= Account.find(rodauth.session_value)
  rescue ActiveRecord::RecordNotFound
    rodauth.logout
    rodauth.login_required
  end
  helper_method :current_account # skip if inheriting from ActionController:API
end

This allows us to access the current account in controllers and views:

<p>Authenticated as: <%= current_account.email %></p>

Requiring authentication

We'll likely want to require authentication for certain parts of our app, redirecting the user to the login page if they're not logged in. We can do this in our Rodauth app's routing block, which helps keep the authentication logic encapsulated:

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

    # require authentication for /dashboard/* and /account/* routes
    if r.path.start_with?("/dashboard") || r.path.start_with?("/account")
      rodauth.require_authentication # redirect to login page if not authenticated
    end
  end
end

We can also require authentication at the controller layer:

# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
  private

  def authenticate
    rodauth.require_authentication # redirect to login page if not authenticated
  end
end
# app/controllers/dashboard_controller.rb
class DashboardController < ApplicationController
  before_action :authenticate
end
# app/controllers/posts_controller.rb
class PostsController < ApplicationController
  before_action :authenticate, except: [:index, :show]
end

Routing constraints

You can also require authentication at the Rails router level by using a built-in authenticated routing constraint:

# config/routes.rb
Rails.application.routes.draw do
  constraints Rodauth::Rails.authenticated do
    # ... authenticated routes ...
  end
end

If you want additional conditions, you can pass in a block, which is called with the Rodauth instance:

# config/routes.rb
Rails.application.routes.draw do
  # require multifactor authentication to be setup
  constraints Rodauth::Rails.authenticated { |rodauth| rodauth.uses_two_factor_authentication? } do
    # ...
  end
end

You can specify the Rodauth configuration by passing the configuration name:

# config/routes.rb
Rails.application.routes.draw do
  constraints Rodauth::Rails.authenticated(:admin) do
    # ...
  end
end

If you need something more custom, you can always create the routing constraint manually:

# config/routes.rb
Rails.application.routes.draw do
  constraints -> (r) { !r.env["rodauth"].logged_in? } do # or "rodauth.admin"
    # routes when the user is not logged in
  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 the default set of Rodauth features into the app/views/rodauth directory, which will be automatically picked up by the RodauthController.

You can pass a list of Rodauth features to the generator to create views for these features (this will not remove any existing views):

$ rails generate rodauth:views login create_account lockout otp

Or you can generate views for all features:

$ rails generate rodauth:views --all

You can also tell the generator to create views into another directory (in this case make sure to rename the Rodauth controller accordingly):

# generates views into app/views/authentication
$ rails generate rodauth:views --name authentication

Layout

To use different layouts for different Rodauth views, you can compare the request path in the layout method:

class RodauthController < ApplicationController
  layout :rodauth_layout

  private

  def rodauth_layout
    case request.path
    when rodauth.login_path,
         rodauth.create_account_path,
         rodauth.verify_account_path,
         rodauth.reset_password_path,
         rodauth.reset_password_request_path
      "authentication"
    else
      "dashboard"
    end
  end
end

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(recipient, email_link)
    # ...
  end
  def reset_password(recipient, email_link)
    # ...
  end
  def verify_login_change(recipient, old_login, new_login, email_link)
    # ...
  end
  def password_changed(recipient)
    # ...
  end
  # def email_auth(recipient, email_link)
  # ...
  # end
  # def unlock_account(recipient, email_link)
  # ...
  # end
end
# app/lib/rodauth_app.rb
class RodauthApp < Rodauth::Rails::App
  configure do
    # ...
    create_reset_password_email do
      RodauthMailer.reset_password(email_to, reset_password_email_link)
    end
    create_verify_account_email do
      RodauthMailer.verify_account(email_to, verify_account_email_link)
    end
    create_verify_login_change_email do |login|
      RodauthMailer.verify_login_change(login, verify_login_change_old_login, verify_login_change_new_login, verify_login_change_email_link)
    end
    create_password_changed_email do
      RodauthMailer.password_changed(email_to)
    end
    # create_email_auth_email do
    #   RodauthMailer.email_auth(email_to, email_auth_email_link)
    # end
    # create_unlock_account_email do
    #   RodauthMailer.unlock_account(email_to, unlock_account_email_link)
    # end
    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. It's generally recommended to send emails asynchronously for better request throughput and the ability to retry deliveries. However, if you want to send emails synchronously, 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, this two-phase API might not be suitable. In this case, instead of overriding #create_*_email and #send_email, override the #send_*_email methods instead, which are required to send the email immediately.

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 corresponding 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

Multiple configurations

If you need to handle multiple types of accounts that require different authentication logic, you can create different configurations for them:

# app/lib/rodauth_app.rb
class RodauthApp < Rodauth::Rails::App
  # primary configuration
  configure do
    # ...
  end

  # alternative configuration
  configure(:admin) do
    # ... enable features ...
    prefix "/admin"
    session_key_prefix "admin_"
    remember_cookie_key "_admin_remember" # if using remember feature

    # if you want separate tables
    accounts_table :admin_accounts
    password_hash_table :admin_account_password_hashes
    # ...
  end

  route do |r|
    r.rodauth

    r.on "admin" do
      r.rodauth(:admin)
      r.pass # allow the Rails app to handle other "/admin/*" requests
    end

    # ...
  end
end

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

rodauth(:admin).login_path #=> "/admin/login"

Named auth classes

A configure block inside Rodauth::Rails::App will internally create an anonymous Rodauth::Auth subclass, and register it under the given name. However, you can also define the auth classes explicitly, by creating subclasses of Rodauth::Rails::Auth:

# app/lib/rodauth_main.rb
class RodauthMain < Rodauth::Rails::Auth
  configure do
    # ... main configuration ...
  end
end
# app/lib/rodauth_admin.rb
class RodauthAdmin < Rodauth::Rails::Auth
  configure do
    # ...
    prefix "/admin"
    session_key_prefix "admin_"
    # ...
  end
end
# app/lib/rodauth_app.rb
class RodauthApp < Rodauth::Rails::App
  configure RodauthMain
  configure RodauthAdmin, :admin
  # ...
end

This allows having each configuration in a dedicated file, and named constants improve introspection and error messages. You can also use inheritance to share common settings:

# app/lib/rodauth_base.rb
class RodauthBase < Rodauth::Rails::Auth
  # common settings that can be shared between multiple configurations
  configure do
    enable :login, :logout
    login_return_to_requested_location? true
    logout_redirect "/"
    # ...
  end
end
# app/lib/rodauth_main.rb
class RodauthMain < RodauthBase # inherit common settings
  configure do
    # ... customize main ...
  end
end
# app/lib/rodauth_admin.rb
class RodauthAdmin < RodauthBase # inherit common settings
  configure do
    # ... customize admin ...
  end
end

Another benefit is that you can define custom methods directly on the class instead of through auth_class_eval:

# app/lib/rodauth_admin.rb
class RodauthAdmin < Rodauth::Rails::Auth
  configure do
    # ...
  end

  def superadmin?
    Role.where(account_id: session_id, type: "superadmin").any?
  end
end
# config/routes.rb
Rails.application.routes.draw do
  constraints Rodauth::Rails.authenticated(:admin) { |rodauth| rodauth.superadmin? } do
    mount Sidekiq::Web => "sidekiq"
  end
end

Calling controller methods

When using Rodauth before/after hooks or generally overriding your Rodauth configuration, in some cases you might want to call methods defined on your controllers. You can do so with rails_controller_eval, for example:

# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
  private
  def setup_tracking(account_id)
    # ... some implementation ...
  end
end
# app/lib/rodauth_app.rb
class RodauthApp < Rodauth::Rails::App
  configure do
    after_create_account do
      rails_controller_eval { setup_tracking(account_id) }
    end
  end
end

Rodauth instance

In some cases you might need to use Rodauth more programmatically, and perform Rodauth operations outside of the request context. rodauth-rails gives you the ability to retrieve the Rodauth instance:

rodauth = Rodauth::Rails.rodauth # or Rodauth::Rails.rodauth(:admin)

rodauth.login_url #=> "https://example.com/login"
rodauth.account_from_login("user@example.com") # loads user by email
rodauth.password_match?("secret") #=> true
rodauth.setup_account_verification
rodauth.close_account

This Rodauth instance will be initialized with basic Rack env that allows is it to generate URLs, using config.action_mailer.default_url_options options.

How it works

Middleware

rodauth-rails inserts a Rodauth::Rails::Middleware into your middleware stack, which calls your Rodauth app for each request, before the request reaches the Rails router.

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

The Rodauth app stores the Rodauth::Auth instance in the Rack env hash, which is then available in your Rails app:

request.env["rodauth"]       #=> #<Rodauth::Auth>
request.env["rodauth.admin"] #=> #<Rodauth::Auth> (if using multiple configurations)

For convenience, this object can be accessed via the #rodauth method in views and controllers:

class MyController < ApplicationController
  def my_action
    rodauth         #=> #<Rodauth::Auth>
    rodauth(:admin) #=> #<Rodauth::Auth> (if using multiple configurations)
  end
end
<% rodauth         #=> #<Rodauth::Auth> %>
<% rodauth(:admin) #=> #<Rodauth::Auth> (if using multiple configurations) %>

App

The Rodauth::Rails::App class is a Roda subclass that provides Rails integration for Rodauth:

  • uses Action Dispatch flash instead of Roda's
  • uses Action Dispatch CSRF protection instead of Roda's
  • sets HMAC secret to Rails' secret key base
  • uses Action Controller for rendering templates
  • runs Action Controller callbacks & rescue handlers around Rodauth actions
  • uses Action Mailer for sending emails

The configure method wraps configuring the Rodauth plugin, forwarding any additional plugin options.

class RodauthApp < Rodauth::Rails::App
  configure { ... }             # defining default Rodauth configuration
  configure(json: true) { ... } # passing options to the Rodauth plugin
  configure(:admin) { ... }     # defining multiple Rodauth configurations
end

The route block is provided by Roda, and it's called on each request before it reaches the Rails router.

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

Since Rodauth::Rails::App is just a Roda subclass, you can do anything you would with a Roda app, such as loading additional Roda plugins:

class RodauthApp < Rodauth::Rails::App
  plugin :request_headers # easier access to request headers
  plugin :typecast_params # methods for conversion of request params
  plugin :default_headers, { "Foo" => "Bar" }
  # ...
end

Sequel

Rodauth uses the Sequel library for database queries, due to more advanced database usage (SQL expressions, database-agnostic date arithmetic, SQL function calls).

If ActiveRecord is used in the application, the rodauth:install generator will have automatically configured Sequel to reuse ActiveRecord's database connection, using the sequel-activerecord_connection gem.

This means that, from the usage perspective, Sequel can be considered just as an implementation detail of Rodauth.

JSON API

To make Rodauth endpoints accessible via JSON API, enable the json feature:

# app/lib/rodauth_app.rb
class RodauthApp < Rodauth::Rails::App
  configure do
    # ...
    enable :json
    only_json? true # accept only JSON requests
    # ...
  end
end

This will store account session data into the Rails session. If you rather want stateless token-based authentication via the Authorization header, enable the jwt feature (which builds on top of the json feature) and add the JWT gem to the Gemfile:

$ bundle add jwt
# app/lib/rodauth_app.rb
class RodauthApp < Rodauth::Rails::App
  configure do
    # ...
    enable :jwt
    jwt_secret "<YOUR_SECRET_KEY>" # store the JWT secret in a safe place
    only_json? true # accept only JSON requests
    # ...
  end
end

If you need Cross-Origin Resource Sharing and/or JWT refresh tokens, enable the corresponding Rodauth features and create the necessary tables:

$ rails generate rodauth:migration jwt_refresh
$ rails db:migrate
# app/lib/rodauth_app.rb
class RodauthApp < Rodauth::Rails::App
  configure do
    # ...
    enable :jwt, :jwt_cors, :jwt_refresh
    # ...
  end
end

OmniAuth

While Rodauth doesn't yet come with OmniAuth integration, we can build one ourselves using the existing Rodauth API.

In order to allow the user to login via multiple external providers, let's create an account_identities table that will have a many-to-one relationship with the accounts table:

$ rails generate model AccountIdentity
# db/migrate/*_create_account_identities.rb
class CreateAccountIdentities < ActiveRecord::Migration
  def change
    create_table :account_identities do |t|
      t.references :account, null: false, foreign_key: { on_delete: :cascade }
      t.string :provider, null: false
      t.string :uid, null: false
      t.jsonb :info, null: false, default: {} # adjust JSON column type for your database

      t.timestamps

      t.index [:provider, :uid], unique: true
    end
  end
end
# app/models/account_identity.rb
class AcccountIdentity < ApplicationRecord
  belongs_to :account
end
# app/models/account.rb
class Account < ApplicationRecord
  has_many :identities, class_name: "AccountIdentity"
end

Let's assume we want to implement Facebook login, and have added the corresponding OmniAuth strategy to the middleware stack, together with an authorization link on the login form:

Rails.application.config.middleware.use OmniAuth::Builder do
  provider :facebook, ENV["FACEBOOK_APP_ID"], ENV["FACEBOOK_APP_SECRET"],
    scope: "email", callback_path: "/auth/facebook/callback"
end
<%= link_to "Login via Facebook", "/auth/facebook" %>

Let's implement the OmniAuth callback endpoint on our Rodauth controller:

# config/routes.rb
Rails.application.routes.draw do
  # ...
  get "/auth/:provider/callback", to: "rodauth#omniauth"
end
# app/controllres/rodauth_controller.rb
class RodauthController < ApplicationController
  def omniauth
    auth = request.env["omniauth.auth"]

    # attempt to find existing identity directly
    identity = AccountIdentity.find_by(provider: auth["provider"], uid: auth["uid"])

    if identity
      # update any external info changes
      identity.update!(info: auth["info"])
      # set account from identity
      account = identity.account
    end

    # attempt to find an existing account by email
    account ||= Account.find_by(email: auth["info"]["email"])

    # disallow login if account is not verified
    if account && account.status != rodauth.account_open_status_value
      redirect_to rodauth.login_path, alert: rodauth.unverified_account_message
      return
    end

    # create new account if it doesn't exist
    unless account
      account = Account.create!(email: auth["info"]["email"], status: rodauth.account_open_status_value)
    end

    # create new identity if it doesn't exist
    unless identity
      account.identities.create!(provider: auth["provider"], uid: auth["uid"], info: auth["info"])
    end

    # login with Rodauth
    rodauth.account_from_login(account.email)
    rodauth.login("omniauth")
  end
end

Configuring

For the list of configuration methods provided by Rodauth, see the feature documentation.

The rails feature rodauth-rails loads is customizable as well, here is the list of its 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.

The Rodauth::Rails module has a few config settings available as well:

Name Description
app Constant name of your Rodauth app, which is called by the middleware.
middleware Whether to insert the middleware into the Rails application's middleware stack. Defaults to true.
# config/initializers/rodauth.rb
Rodauth::Rails.configure do |config|
  config.app = "RodauthApp"
  config.middleware = true
end

Custom extensions

When developing custom extensions for Rodauth inside your Rails project, it's better to use plain modules (at least in the beginning), because Rodauth feature design doesn't yet support Zeitwerk reloading well. Here is an example of an LDAP authentication extension that uses the simple_ldap_authenticator gem.

# app/lib/rodauth_ldap.rb
module RodauthLdap
  def require_bcrypt?
    false
  end

  def password_match?(password)
    SimpleLdapAuthenticator.valid?(account[:email], password)
  end
end
# app/lib/rodauth_app.rb
class RodauthApp < Rodauth::Rails::App
  configure do
    # ...
    auth_class_eval do
      include RodauthLdap
    end
    # ...
  end
end

Testing

System (browser) tests for Rodauth actions could look something like this:

# test/system/authentication_test.rb
require "test_helper"

class AuthenticationTest < ActionDispatch::SystemTestCase
  include ActiveJob::TestHelper
  driven_by :rack_test

  test "creating and verifying an account" do
    create_account
    assert_match "An email has been sent to you with a link to verify your account", page.text

    verify_account
    assert_match "Your account has been verified", page.text
  end

  test "logging in and logging out" do
    create_account(verify: true)

    logout
    assert_match "You have been logged out", page.text

    login
    assert_match "You have been logged in", page.text
  end

  private

  def create_account(email: "user@example.com", password: "secret", verify: false)
    visit "/create-account"
    fill_in "Login", with: email
    fill_in "Password", with: password
    fill_in "Confirm Password", with: password
    click_on "Create Account"
    verify_account if verify
  end

  def verify_account
    perform_enqueued_jobs # run enqueued email deliveries
    email = ActionMailer::Base.deliveries.last
    verify_account_link = email.body.to_s[/\S+verify-account\S+/]
    visit verify_account_link
    click_on "Verify Account"
  end

  def login(email: "user@example.com", password: "secret")
    visit "/login"
    fill_in "Login", with: email
    fill_in "Password", with: password
    click_on "Login"
  end

  def logout
    visit "/logout"
    click_on "Logout"
  end
end

While request tests in JSON API mode with JWT tokens could look something like this:

# test/integration/authentication_test.rb
require "test_helper"

class AuthenticationTest < ActionDispatch::IntegrationTest
  test "creating and verifying an account" do
    create_account
    assert_response :success
    assert_match "An email has been sent to you with a link to verify your account", JSON.parse(body)["success"]

    verify_account
    assert_response :success
    assert_match "Your account has been verified", JSON.parse(body)["success"]
  end

  test "logging in and logging out" do
    create_account(verify: true)

    logout
    assert_response :success
    assert_match "You have been logged out", JSON.parse(body)["success"]

    login
    assert_response :success
    assert_match "You have been logged in", JSON.parse(body)["success"]
  end

  private

  def create_account(email: "user@example.com", password: "secret", verify: false)
    post "/create-account", as: :json, params: { login: email, password: password, "password-confirm": password }
    verify_account if verify
  end

  def verify_account
    perform_enqueued_jobs # run enqueued email deliveries
    email = ActionMailer::Base.deliveries.last
    verify_account_key = email.body.to_s[/verify-account\?key=(\S+)/, 1]
    post "/verify-account", as: :json, params: { key: verify_account_key }
  end

  def login(email: "user@example.com", password: "secret")
    post "/login", as: :json, params: { login: email, password: password }
  end

  def logout
    post "/logout", as: :json, headers: { "Authorization" => headers["Authorization"] }
  end
end

If you're delivering emails in the background, make sure to set Active Job queue adapter to :test or :inline:

# config/environments/test.rb
Rails.application.configure do |config|
  # ...
  config.active_job.queue_adapter = :test # or :inline
  # ...
end

If you need to create an account record with a password directly, you can do it as follows:

# app/models/account.rb
class Account < ApplicationRecord
  has_one :password_hash, foreign_key: :id
end
# app/models/account/password_hash.rb
class Account::PasswordHash < ApplicationRecord
  belongs_to :account, foreign_key: :id
end
require "bcrypt"

account = Account.create!(email: "user@example.com", status: "verified")
password_hash = BCrypt::Password.create("secret", cost: BCrypt::Engine::MIN_COST)
account.create_password_hash!(password_hash: password_hash)

Rodauth defaults

rodauth-rails changes some of the default Rodauth settings for easier setup:

Database functions

By default, on PostgreSQL, MySQL, and Microsoft SQL Server Rodauth uses database functions to access password hashes, with the user running the application unable to get direct access to password hashes. This reduces the risk of an attacker being able to access password hashes and use them to attack other sites.

While this is useful additional security, it is also more complex to set up and to reason about, as it requires having two different database users and making sure the correct migration is run for the correct user.

To keep with Rails' "convention over configuration" doctrine, rodauth-rails disables the use of database functions, though you can always turn it back on.

use_database_authentication_functions? true

To create the database functions, pass the Sequel database object into the Rodauth method for creating database functions:

# db/migrate/*_create_rodauth_database_functions.rb
require "rodauth/migrations"

class CreateRodauthDatabaseFunctions < ActiveRecord::Migration
  def up
    Rodauth.create_database_authentication_functions(DB)
  end

  def down
    Rodauth.drop_database_authentication_functions(DB)
  end
end

Account statuses

The recommended Rodauth migration stores possible account status values in a separate table, and creates a foreign key on the accounts table, which ensures only a valid status value will be persisted.

Unfortunately, this doesn't work when the database is restored from the schema file, in which case the account statuses table will be empty. This happens in tests by default, but it's also commonly done in development.

To address this, rodauth-rails modifies the setup to store account status text directly in the accounts table. If you're worried about invalid status values creeping in, you may use enums instead. Alternatively, you can always go back to the setup recommended by Rodauth.

# in the migration:
create_table :account_statuses do |t|
  t.string :name, null: false, unique: true
end
execute "INSERT INTO account_statuses (id, name) VALUES (1, 'Unverified'), (2, 'Verified'), (3, 'Closed')"

create_table :accounts do |t|
  # ...
  t.references :status, foreign_key: { to_table: :account_statuses }, null: false, default: 1
  # ...
end
configure do
  # ...
- account_status_column :status
- account_unverified_status_value "unverified"
- account_open_status_value "verified"
- account_closed_status_value "closed"
  # ...
end

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.