Janko MarohniΔ
Posted on August 18, 2020
If you're working with Rails, chances are your authentication layer is implemented using one of the popular authentication frameworks β Devise, Sorcery, Clearance, or Authlogic. These libraries provide complete authentication and account management functionality for Rails, giving you more time to focus on the core business logic of your product.
One characteristic these authentication frameworks have in common is that they're all built on top of Rails. This means that they plug into the existing Rails components (models, controllers, routes), and generally try to follow "The Rails Way" of doing things.
However, libraries that build on top of Rails often tend to inherit some of Rails' anti-patterns as well, such as having high coupling, burdening models and controllers with additional responsibilities, and overusing Active Record callbacks. Having had my fair share of experience with the Ruby ecosystem outside of Rails' default menu, I've come to learn that taking the effort to make your library's implementation decoupled from Rails can provide significant advantages:
- breaking away from Rails gives you mental space to design your library better
- your library can now be used with other Ruby web frameworks too π
- supporting new Rails versions becomes easier too
If one wanted to implement authentication in another Ruby web framework, the most frequent advice seems to be to use Warden. Warden is a Rack-based library that provides a mechanism for authentication, with support for multiple strategies. However, the problem is that Warden doesn't actually do anything by itself. You still need to implement login, remembering, registration, account verification, password reset and other functionality yourself (which is what Devise does), and I'd argue this is actually the hard part π
Enter Rodauth
We've actually had a better solution for some time now, it just hasn't received enough attention. Jeremy Evans β a Ruby Hero, a Ruby committer, and an author of numerous Ruby libraries (most popular of which is Sequel and Roda) β has been developing a new full-featured authentication framework for the past several years, called Rodauth. Recently I've finally had the opportunity to integrate Rodauth into a Rails app at work, and I can safely say that its tagline β "Ruby's most advanced authentication framework" β is in no way exaggerated π
One of the first things you'll notice is that, in constrast to other authentication frameworks which are built on top of Rails and Active Record, Rodauth is implemented using Roda and Sequel. As a big fan of both libraries, for me personally this was exciting, but at the same time I was worried this meant Rodauth cannot be used with Rails. However, it turns out Rodauth can in fact be used with any Rack-based web framework, including Rails.
Integrating Rodauth into Rails for the first time definitely wasn't trivial, but once all the kinks had been worked out, I extracted the necessary glue code into rodauth-rails. It comes with generators, controller & view integration, mailer support, CSRF & flash integration, HMAC security and more. Additionally, to make Sequel coexist better with Active Record, I've created the sequel-activerecord_connection gem, which enables Sequel to reuse Active Record's database connection. All this should make Rodauth as easy to get started with as its Rails-based counterparts πͺ
And with its recent 2.0 release, it's time to finally take a proper look into Rodauth and see what makes it so special β¨
Encapsulated authentication logic
As we've touched on before, what characterizes most Rails-based authentication frameworks is that they implement their authentication behaviour directly on the MVC layer. While this approach keeps things close to Rails, it also shoves additional responsibilities into already-heavy Rails components, and generally causes the authentication logic to be spread out across multiple application layers.
With Rodauth, all authentication behaviour is encapsulated in a special Rodauth::Auth
object, which is created inside a Roda middleware and has access to the request context. It handles everything from routing requests to Rodauth endpoints to performing authentication-related commands and queries. We configure it as a Roda plugin and can we use it in Roda's routing block to perform any actions before the request reaches the main app. This design enables us to keep our authentication logic contained in a single file.
class RodauthMiddleware < Roda
# define your Rodauth configuration
plugin :rodauth do
# load authentication features you need
enable :login, :logout, :create_account, :verify_account, :reset_password
# change default settings
password_minimum_lenth 8
login_return_to_requested_location? true
reset_password_autologin? true
logout_redirect "/"
# ...
end
# handles requests before they reach the main app
route do |r|
# handle Rodauth paths (/login, /create-account, /reset-password, ...)
r.rodauth
# require authentication for certain routes
if r.path.start_with?("/dashboard")
rodauth.require_authentication
end
end
end
When we add the above Roda app to our middleware stack, the route
block will be called for each request before it reaches our main app, yielding the request object. The r.rodauth
call will handle any Rodauth routes, while rodauth.require_authentication
will redirect to the login page if the session is not authenticated. When the end of the routing block is reached, the request proceeds onto our main app.
If you're using Rails with rodauth-rails, the Rodauth instance will remain available in your controllers and views as well, so you can do things like require authentication at the controller level if you prefer to:
class PostsController < ApplicationController
before_action -> { rodauth.require_authentication }
# ...
end
or render authentication links in the views:
<% if rodauth.authenticated? %>
<%= 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 %>
There are many more useful authentication methods defined on the Rodauth instance that give us additional flexibility and introspection:
rodauth.auhenticated_by # e.g. ["password", "otp"]
rodauth.session_value # returns account id from the session
rodauth.account_from_login("foo@bar.com") # retrieves account with given email address
rodauth.password_match?("secret") # returns whether given password matches current account's password
rodauth.login("password") # logs the account in and redirects with a notice flash
rodauth.logout # logs the session out
# ...
Feature maturity
Rodauth has all of the essential features you already know from other authentication frameworks:
- login/logout and remember
- create account with email verification (and a grace period)
- reset password and change password
- change email with email verification
- lockout and close account
You'll also find most industry-standard security features the devise-security extension provides:
- password expiration and disallowing password reuse
- password complexity checks and disallowing common passwords
- account expiration and single session
There are many other useful features as well:
- HTTP Basic authentication
- email authentication
- password confirmation dialog (with a grace period)
- audit logging
- ...
Multifactor authentication
In addition to the features above, Rodauth also provides multifactor authentication functionality out-of-the-box, supporting multiple MFA methods (TOTP, SMS codes, recovery codes, and WebAuthn). It includes complete endpoints for setup, authentication, and disabling MFA methods, along with the related HTML templates.
Here is an example setup that allows a user to enable TOTP verification for their account, along with a backup SMS number and recovery codes (assuming we've created the necessary database tables):
class RodauthApp < Roda
plugin :rodauth do
enable :otp, :sms_codes, :recovery_codes, ...
# use Twilio to send SMS messages
sms_send do |phone, message|
twilio = Twilio::Rest::Client.new("<ACCOUNT_SID>", "<AUTH_TOKEN>")
twilio.messages.create(body: message, to: phone, from: "<APP_PHONE_NUMBER>")
end
end
end
<!-- somewhere under account settings: -->
<% if rodauth.uses_two_factor_authentication? %>
<%= link_to "Manage MFA", rodauth.two_factor_manage_path %>
<%= link_to "Disable MFA", rodauth.two_factor_disable_path %>
<% else %>
<%= link_to "Setup MFA", rodauth.two_factor_manage_path %>
<% end %>
Having full-featured multifactor authentication built into the framework is a game changer, as it means it will work well with other features and will remain compatible with future Rodauth releases π
JSON API
Another cool feature of Rodauth is its built-in support for JWT, which provides token-based JSON API access for each authentication feature. Here is how we can configure the JWT feature:
class RodauthApp < Roda
plugin :rodauth, json: :only do # 1) enable Roda's JSON support and only allow JSON access
enable :login, :create_account, :change_password, :close_account, :jwt # 2) load JWT feature
jwt_secret "abc123" # 3) set secret for the JWT feature
require_login_confirmation? false
require_password_confirmation? false
end
end
We can now trigger Rodauth actions via a JSON requests, using the Authorization
header for authentication. Here is an example flow using http.rb:
# 1) create an account
response = HTTP.post("https://myapp.com/create-account", json: { login: "foo@example.com", password: "secret" })
token = response.headers["Authorization"]
# 2) change the password
response = HTTP.auth(token).post("https://myapp.com/change-password", json: { password: "secret", "new-password": "new secret" })
# 3) login with the new password
response = HTTP.post("https://myapp.com/login", json: { login: "foo@example.com", password: "new secret" })
token = response.headers["Authorization"]
# 4) close the account
http.auth(token).post("https://myapp.com/close-account", json: { password: "new secret" })
# 5) try to login again
response = HTTP.post("https://myapp.com/login", json: { login: "foo@example.com", password: "new secret" })
response.status.to_s # => "401 Unauthorized"
Other authentication frameworks haven't yet standardized JSON API support:
- Devise has multiple solutions (DeviseTokenAuth, Devise::JWT, SimpleTokenAuthentication)
- Sorcery currently has a few unmerged pull requests (#239, #167, #70)
- Clearance doesn't currently support JSON (comment)
Uniform configuration DSL
If we look at how Devise is customized, we'll notice there are several different layers on which we can configure authentication behaviour: global settings, model settings, controller settings, and routing settings. Some of these settings can be configured dynamically (based on either model or controller state), while other can only be configured statically. And some before/after hooks are triggered on the model level, while for others you need to override controller actions. This is pretty inconsistent π
In contrast, Rodauth provides a uniform configuration DSL that allows changing virtually any authentication behaviour, which is all defined on the Rodauth::Auth
class. You can override a configuration method either by providing a static value, or by passing a dynamic block that gets evaluated in context of a Rodauth::Auth
instance (and you can call super
to get original behaviour).
class RodauthApp < Roda
plugin :rodauth do
# each feature adds its own set of configuration methods
enable :login, :create_account, :verify_account_grace_period, :reset_password
# examples of static values:
login_redirect "/dashboard" # redirect to /dashboard after logging in
verify_account_grace_period 3.days # allow unverified access for 3 days after registration
reset_password_autologin? true # automatically log the user in after password reset
# examples of dynamic blocks:
password_minimum_length { MyConfig.get(:min_password_length) } # change minimum allowed password length
login_valid_email? { |login| TrueMail.valid?(login) } # override email validation logic
verify_account_redirect { login_redirect } # after account verification redirect to wherever login redirects to
end
end
Internaly, Rodauth also provides a DSL for writing new features, which streamlines adding new configuration methods and encourages making the feature behaviour as flexible as possible.
Hooks
Rodauth consistently provides hooks for virtually any action, which we can override inside our configuration block. We can do something before/after specific operations:
after_login { remember_login }
before_create_account { throw_error("company", "must be present") if param("company").empty? }
after_login_failure { LoginAttempts.increment(account[:email]) }
before specific routes:
before_change_login_route { require_password_authentication }
before_create_account_route { redirect "/register" if param("type").empty? }
or before each Rodauth route:
before_rodauth { AuthLogger.call(request) }
Enhanced security
When I would talk to people about Rodauth, one common concern was whether it's secure enough, given that alternatives such as Devise are more widely-used. While I'm not qualified enough to provide a direct answer, what I can say is that there are multiple indications that Jeremy takes Rodauth's security very seriously:
- Rodauth incorporates some additional security measures that are not common in other authentication frameworks (more on this below)
- Jeremy proactively patches Rodauth when new security issues are found in other authentication frameworks
- Jeremy's talks showcase some very advanced knowledge on web application security
Tokens
Many authentication features (remembering logins, password reset, account verification, email change verification etc.) generate random unique tokens as part of their functionality. Since these tokens give permissions for performing sensitive actions, we don't want others having access to them.
When hmac_secret
is set (rodauth-rails sets it automatically), Rodauth will sign the tokens sent via email using HMAC, while the raw tokens will be stored in the database. This will make it so if the tokens in the database are leaked (e.g. via an SQL injection vulnerability), they will not be usable without also having access to the HMAC secret.
hmac_secret "abc123"
For better bruteforce protection, Rodauth tokens also include the account id. This way an attacker can only attempt to bruteforce the token for a single account at a time, instead of being able to bruteforce tokens for all accounts at once.
<account_id>_<random_token>
Protecting password hashes
Because cracking password hashes is always getting faster, to additionally protect password hashes in case of a database breach, Devise and Sorcery enable you to use a password pepper, a secret key added to the password before it's hashed. This is pretty secure, provided that compromise of database doesn't also imply compromise of application secrets. A downside of peppers is that they're not easy to rotate, as changing the pepper would invalidate all existing passwords stored in the database.
bcrypt(password + pepper)
Rodauth offers an alternative approach to protecting password hashes, which relies on restricting database access. Password hashes are stored in a separate table, for which the application database user has INSERT/UPDATE/DELETE access, but not SELECT access. This means an attacker cannot retrieve password hashes even in case of an SQL injection or a remote code execution exploit.
# full access by the app database user
create_table :accounts do
primary_key :id
String :email, null: false, unique: true
end
# *restricted* access by the app database user
create_table :account_password_hashes do
foreign_key :id, :accounts, primary_key: true
String :password_hash, null: false
end
That's great, but without the ability to retrieve password hashes, how is your app then able to check whether an entered password matches the one stored in the database on login? The answer: via a database function, which takes an account id and a password hash, and returns whether the given password hash matches the stored password hash for the given account id. This is effectively a limited SELECT query, but it's all that's needed for login functionality, and it's all the attacker gets in case of a database breach.
SELECT rodauth_valid_password_hash(123, "<some_bcrypt_hash>") -- returns true or false
You can read about this in more detail here. Note that this mode is completely optional, Rodauth works just as well with storing password hashes in the accounts table; in this case it does password matching in Ruby, just like we're used to with other authentication frameworks.
Other design highlights
Decoupled authentication features
Rodauth really did a great job in achieving loose coupling between its authentication features. Additionally, each Rodauth feature is contained entirely in a single file (and a single context), with the code being loaded only if the feature is enabled. All this makes it significantly easier to understand how each individual feature works.
enable :create_account, # create account and automatically login -- lib/features/rodauth/create_account.rb
:verify_account, # require email verification after account creation -- lib/features/rodauth/verify_account.rb
:verify_account_grace_period, # allow unverified login for a certain period of time -- lib/features/rodauth/verify_account_grace_period.rb
:change_login, # change email immediately -- lib/features/rodauth/change_login.rb
:verify_login_change, # require email verification before change is applied -- lib/features/rodauth/verify_login_change.rb
:password_complexity, # add password complexity requirements -- lib/features/rodauth/password_complexity.rb
:disallow_common_passwords, # don't allow using a weak common password -- lib/features/rodauth/disallow_common_passwords.rb
:disallow_password_reuse, # don't allow using a previously used password -- lib/features/rodauth/disallow_password_reuse.rb
Database table per feature
Rodauth features are decoupled not only in code, but also in the database. Instead of adding all columns to a single table, in Rodauth each feature has a dedicated table. This makes it more transparent which database columns belong to which features.
create_table :accounts do ... end # used by base feature
create_table :account_password_reset_keys do ... end # used by reset_password feature
create_table :account_verification_keys do ... end # used by verify_account feature
create_table :account_login_change_keys do ... end # used by verify_login_change feature
create_table :account_remember_keys do ... end # used by remember feature
# ...
Conclusion
Rodauth is one of those libraries that come and renew my interest in the Ruby ecosystem π Its advanced and comprehensive set of authentication features (including multifactor authentication and JSON support), backed by a powerful configuration DSL that provides immense flexibility, provide overwhelming advantages over other authentication frameworks.
As someone who enjoys working in other Ruby web frameworks, I strongly believe in focusing on libraries that are accessible to everyone. And Rodauth is the first full-featured authentication solution that can be used in any Ruby web framework (including Rails).
Hopefully this overview will encourage you to try Rodauth out in your next project π
Further reading
Posted on August 18, 2020
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.