Rodauth

Rodauth is an authentication framework using Roda, Sequel, and PostgreSQL.

Design Goals

  • Security: Ship in a maximum security by default configuration

  • Simplicity: Allow for easy configuration via a DSL

  • Flexibility: Allow for easy overriding of any part of the framework

Features

  • Login

  • Logout

  • Change Password

  • Change Login

  • Reset Password

  • Create Account

  • Close Account

  • Verify Account

  • Remember (Autologin via token)

  • Lockout (Bruteforce protection)

Resources

RDoc

rodauth.jeremyevans.net

Demo Site

rodauth-demo.jeremyevans.net

Source

github.com/jeremyevans/rodauth

Bugs

github.com/jeremyevans/rodauth/issues

Security

Passwords

Passwords are hashed using bcrypt, and the password hashes are kept in a separate table from the accounts table, with a foreign key referencing the accounts table. A PostgreSQL function is added to check the password for a given application account matches.

A separate database account owns the table containing the password hashes, which the application database account cannot access. The application database account has the ability to execute the function to check the password, but not the ability to access the password hashes directly, making it much more difficult for an attacker to access the password hashes even if they are able to exploit an SQL injection or remote code execution vulnerability in the application. Even if an attacker was able to exploit a vulnerability in the application, they would only be to check if a specific password matches for a given user, which is the same access an attacker would have anyway if they just tried to login.

While the application database account is not be able to read password hashes, it is still be able to insert password hashes, update passwords hashes, and delete password hashes, so the additional security is not that painful.

The reason for extra security in regards to password hashes stems from the fact that people tend to reuse passwords, so a compromise of one site can result in account access on other sites, making password hash storage of critical importance even if the other data stored is not that important.

If you are storing other important information in your database, you should consider using a similar approach in other areas (or all areas) of your application.

Rodauth can still be used if you are using a more convential approach of storing the password hash in a column in the same table, with a single configuration setting.

Tokens

Account verification, password resets, remember, and lockout tokens all use a similar approach. They all provide a token, in the format “account-id_long-random-string”. By including the id of the account in the token, an attacker can only attempt to bruteforce the token for a single account, instead of being able to bruteforce tokens for all accounts at once (which would be possible if the token was just a random string).

There is a maximum of 1 token per account for each of these features at a time. This prevents attackers from creating an arbitrary number of requests in order to make bruteforcing easier.

Database Setup

In order to get full advantages of Rodauth’s security design, multiple database accounts are involved:

1) database superuser account (usually postgres) 2) application database account 3) secondary database account

The database superuser account is used to load extensions related to the database. The application should never be run using the database superuser account.

Note that there is not a simple way to use multiple database accounts in the same PostgreSQL database on Heroku. You can still use Rodauth on Heroku, it just won’t have the same security benefits. That’s not to say it is insecure, just that it drops the security level for password hash storage to the same level as other common authentication solutions.

Load extensions

If you want to use the login features for Rodauth, you need to load the pgcrypto extension with the database superuser account, and load the citext extension if you want to support case insensitive logins.

Example:

echo "CREATE EXTENSION pgcrypto" | psql -U postgres $database_name
echo "CREATE EXTENSION citext" | psql -U postgres $database_name

Note that on Heroku, both of these extensions can be loaded using a standard database account.

Create database accounts

If you are currently running your application using the database superuser account, the first thing you need to do is to create a database account for the application. It’s often best to name this account the same as the database name.

You should also create a second database account which will own the password hash table.

Example:

createuser -U postgres $database_name
createuser -U postgres $database_name_password_hashes

Note that if the database superuser account owns all of the items in the database, you’ll need to change the ownership to the database account you just created. See gist.github.com/jeremyevans/8483320 for a way to do that.

Create tables

Because two different database accounts are used, two different migrations are required, one for each database account. Here are example migrations. You can modify them to add support for additional columns, or remove tables or columns related to features that you don’t need.

First migration, run using the application database account:

Sequel.migration do
  up do
    # Used by the account verification and close account features
    create_table(:account_statuses) do
      Integer :id, :primary_key=>true
      String :name, :null=>false, :unique=>true
    end
    from(:account_statuses).import([:id, :name], [[1, 'Unverified'], [2, 'Verified'], [3, 'Closed']])

    # Used by the create account, account verification,
    # and close account features.
    create_table(:accounts) do
      primary_key :id, :type=>Bignum
      foreign_key :status_id, :account_statuses, :null=>false, :default=>1
      citext :email, :null=>false

      constraint :valid_email, :email=>/^[^,;@ \r\n]+@[^,@; \r\n]+\.[^,@; \r\n]+$/
      index :email, :unique=>true, :where=>{:status_id=>[1, 2]}
    end

    # Used by the password reset feature
    create_table(:account_password_reset_keys) do
      foreign_key :id, :accounts, :primary_key=>true, :type=>Bignum
      String :key, :null=>false
      DateTime :deadline, :null=>false, :default=>Sequel.lit("CURRENT_TIMESTAMP + '1 day'")
    end

    # Used by the account verification feature
    create_table(:account_verification_keys) do
      foreign_key :id, :accounts, :primary_key=>true, :type=>Bignum
      String :key, :null=>false
    end

    # Used by the remember me feature
    create_table(:account_remember_keys) do
      foreign_key :id, :accounts, :primary_key=>true, :type=>Bignum
      String :key, :null=>false
      DateTime :deadline, :null=>false, :default=>Sequel.lit("CURRENT_TIMESTAMP + '2 weeks'")
    end

    # Used by the lockout feature
    create_table(:account_login_failures) do
      foreign_key :id, :accounts, :primary_key=>true, :type=>Bignum
      Integer :number, :null=>false, :default=>1
    end
    create_table(:account_lockouts) do
      foreign_key :id, :accounts, :primary_key=>true, :type=>Bignum
      String :key, :null=>false
      DateTime :deadline, :null=>false, :default=>Sequel.lit("CURRENT_TIMESTAMP + '1 day'")
    end

    # Grant password user access to reference accounts
    pw_user = get{Sequel.lit('current_user')} + '_password'
    run "GRANT REFERENCES ON accounts TO #{pw_user}"
  end

  down do
    drop_table(:account_lockouts, :account_login_failures, :account_remember_keys,
      :account_verification_keys, :account_password_reset_keys, :accounts, :account_statuses)
  end
end

Second migration, run using the secondary database account:

Sequel.migration do
  up do
    # Used by the login and change password features
    create_table(:account_password_hashes) do
      foreign_key :id, :accounts, :primary_key=>true, :type=>Bignum
      String :password_hash, :null=>false
    end

    # Function used to check if a password is valid.  Takes the related account id
    # and unencrypted password, checks if password matches password hash.
    run "CREATE OR REPLACE FUNCTION account_valid_password(account_id int8, password text) RETURNS boolean AS $$\nDECLARE valid boolean;\nBEGIN\nSELECT password_hash = crypt($2, password_hash) INTO valid \nFROM account_password_hashes\nWHERE account_id = id;\nRETURN valid;\nEND;\n$$ LANGUAGE plpgsql\nSECURITY DEFINER\nSET search_path = public, pg_temp;\n"

    # Restrict access to the password hash table
    app_user = get{Sequel.lit('current_user')}.sub(/_password\z/, '')
    run "REVOKE ALL ON account_password_hashes FROM public"
    run "REVOKE ALL ON FUNCTION account_valid_password(int8, text) FROM public"
    run "GRANT INSERT, UPDATE, DELETE ON account_password_hashes TO #{app_user}"
    run "GRANT SELECT(id) ON account_password_hashes TO #{app_user}"
    run "GRANT EXECUTE ON FUNCTION account_valid_password(int8, text) TO #{app_user}"
  end

  down do
    run "DROP FUNCTION account_valid_password(int8, text)"
    drop_table(:account_password_hashes)
  end
end

If you are using a non-PostgreSQL database or cannot use multiple user accounts, just combine the two migrations into a single migration and exclude the GRANT/REVOKE statements.

One thing to notice in the above migrations is that Rodauth uses additional tables for additional features, instead of additional columns in a single table.

Usage

Basic Usage

Rodauth is a Roda plugin and loaded the same way other Roda plugins are loaded:

plugin :rodauth do
end

The block passed to the plugin call uses the Rodauth configuration DSL. The one configuration method that should always be used is enable, which chooses which features you would like to load:

plugin :rodauth do
  enable :login, :logout
end

Once features are loaded, you can use any of the configuration methods supported by the features. There are three types of configuration methods. The first type are called auth methods, and they take a block, and overrides the default method that Rodauth uses. Inside the block, you can call super if you want to get the default behavior. For example, if you want to add additional logging when a user logs in:

plugin :rodauth do
  enable :login, :logout
   do
    logger.info "#{account.email} logged in!"
    super
  end
end

Inside the block, you are in the context of the Rodauth::Auth instance related to the request. This object has access to everything related to the request via methods:

request

RodaRequest instance

response

RodaResponse instance

scope

Roda instance

session

session hash

flash

flash message hash

account

account model instance (if set by an earlier Rodauth method)

So if you want to log the IP address for the user during login:

plugin :rodauth do
  enable :login, :logout
   do
    logger.info "#{account.email} logged in from #{request.ip}"
    super
  end
end

The second type of configuration methods are called auth value methods. They are similar to auth methods, but instead of just accepting a block, they can optionally accept a single argument without a block, which will be treated as a block that just returns that value. For example, the account_model method sets the model class to use for the account, so to override it, you can call the method with another class:

plugin :rodauth do
  enable :login, :logout
   User
end

The third type of configuration methods are called auth block methods, and there are three of them per feature, one for handling the route itself, one for handling just the GET route, and one for handling just the POST route. For the login feature, login_route_block would set the routing block to use if the login route matches, login_get_block would set the routing block to use if the login route matches and it is a GET request, and login_post_block would set the routing block to use if the login route matches and it is a POST request. As auth block methods specify the route blocks, they are executed in the context of the Roda instance, and are passed two arguments, the first being the RodaRequest instance, and the second being the Rodauth::Auth instance. For example, if you wanted to override how a POST request to the login route is handled:

plugin :rodauth do
  enable :login
   do |r, auth|
    # ...
  end
end

By allowing every configuration method to take a block, Rodauth should be flexible enough to integrate into most legacy authentication systems.

Feature Documentation

The options/methods for the supported features are listed on a separate page per feature. If these links are not active, please view the appropriate file in the doc directory.

Since the auth block methods work the same way for each of these features, they are not documented on the feature pages. Additionally, all features have a before auth method (e.g. before_login) that is called before either the GET or POST route blocks are handled.

With Multiple Configurations

Rodauth supports using multiple rodauth configurations in the same application. You just need to load the plugin a second time, providing a name for any alternate configuration:

plugin :rodauth do
end
plugin :rodauth, :name=>:secondary do
end

Then in your routing code, any time you call rodauth, you can provide the name as an argument to use that configuration:

route do |r|
  r.on 'secondary' do
    r.rodauth(:secondary)
  end

  r.rodauth
end

With Other Databases

You can use Rodauth with other databases besides PostgreSQL. Assuming you are storing the password hashes in the same table as the account information, you can just do:

plugin :rodauth do
   :password_hash
end

When this option is set, Rodauth will not use a database function to authenticate, it will do the check in ruby. This feature can also be used if you are using PostgreSQL, but for legacy reasons are storing the password hashes in the same table as the account information.

The Rodauth lockout feature also uses UPDATE RETURNING to update a row and return the new value, so if you are not using PostgreSQL and wish to use the lockout feature, you’ll need to override the invalid_login_attempted method.

With Other Web Frameworks

You can use Rodauth even if your application does not use the Roda web framework. This is possible by adding a Roda middleware that uses Rodauth:

require 'roda'

class RodauthApp < Roda
  plugin :middleware
  plugin :rodauth
  route do |r|
    r.rodauth
  end
end

use RodauthApp

Using External Features

The enable configuration method is able to load features external to Rodauth. You need to place the external feature file where it can be required via roda/plugins/rodauth/feature_name. That file should use the following basic structure

class Roda
  module RodaPlugins
    module Rodauth
      # :feature_name will be the argument given to enable to
      # load the feature
      FeatureName = Feature.define(:feature_name) do
        auth_value_methods # one argument per auth value method
        auth_methods # one argument per auth method

        get_block do |r, auth|
          # r is the RodaRequest instance
          # auth is the Rodauth::Auth instance
          # This block is evaluated in the scope of the Roda instance
          # ...
        end

        post_block do |r, auth|
          # ...
        end

        # define the default behavior for the auth methods
        # and auth value methods
        # ...
      end
    end
  end
end

Possible Future Directions

  • OmniAuth support. This is not something I plan to work on myself, but I will consider patches that add it.

Similar Projects

All of these are Rails-specific:

  • Devise

  • Authlogic

  • Sorcery

Author

Jeremy Evans <[email protected]>