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
- Demo Site
- Source
- Bugs
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
after_login 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
after_login 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
account_model 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
login_post_route 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.
-
Base (this feature is autoloaded)
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
account_password_hash_column :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]>