Gridauth
Grid card two-factor authentication for Rails 8+ applications that use the
built-in authentication generator (bin/rails generate authentication). It
does not use Devise.
A grid card is a small table of random characters, with lettered columns and numbered rows, that the user prints or saves:
A B C D E F G H I J
1 7K QF M3 ...
2 ...
After the usual email and password form, the user is asked for the characters in a few cells, for example B3, F1 and J4. No session is created until those answers are correct.
Features
-
One card per user, generated with
SecureRandom. The database stores only per-cell HMAC-SHA256 digests, so the card can't be read back from it. - Enrollment: the user issues a card, views or prints it, and activates it by answering a challenge from the new card. This proves they saved it.
-
Rotation: issuing a new card leaves the current one working until the
new one is activated. Then the old card is revoked. Cards can be due for
rotation by age (
rotation_period), by number of sign-ins (rotate_after_uses), or both. Rotation can be required (enforce_rotation). -
Sign-in challenge after the password step, with:
- challenge cells that stay fixed until answered correctly, so repeated sign-ins can't be used to "shop" for cells an attacker knows
- constant-time comparison of every cell
- a lockout after repeated failures, plus Rails
rate_limit - a time limit for the second step
-
reset_sessionon both steps to prevent session fixation
-
Optional enforcement: every user can be required to enroll
(
enforce_enrollment). - Generators: the install generator wires everything up. The views generator copies the views into your app so you can customize them.
-
I18n: every message is in
config/locales/en.yml.
Requirements
- Rails 8.0 or later
- Ruby 3.2 or later
- An app that already ran
bin/rails generate authentication(User,Session,Currentand theAuthenticationconcern)
Installation
# Gemfile
gem "gridauth"bundle install
bin/rails generate gridauth:install
bin/rails db:migrateThe install generator:
-
creates
config/initializers/gridauth.rb -
creates the
gridauth_grid_cardsmigration -
adds
has_grid_cardtoapp/models/user.rb -
adds
include Gridauth::AuthenticationtoApplicationController, afterinclude Authentication -
changes
SessionsController#createfromstart_new_session_for user redirect_to after_authentication_url
to
start_grid_card_challenge_for user
-
adds
gridauth_routestoconfig/routes.rb
If your SessionsController has been customized, the generator prints a
message and you make step 5 by hand. Users without an active card still sign
in with just their password.
Then give signed-in users a link to their card settings:
<%= link_to "Two-factor authentication", gridauth_grid_card_path %>Routes
gridauth_routes draws the routes straight into your app's route set (like
devise_for), so the pages render in your layout and your route helpers work
there. To use a different URL prefix, call gridauth_routes path: "two-factor".
| Helper | Request | Purpose |
|---|---|---|
new_gridauth_challenge_path |
GET /gridauth/challenge/new |
Challenge form shown after the password step |
gridauth_challenge_path |
POST /gridauth/challenge |
Check the answers and create the session |
gridauth_challenge_path |
DELETE /gridauth/challenge |
Cancel the sign-in |
gridauth_grid_card_path |
GET /gridauth/card |
Card status, enrollment and rotation |
gridauth_grid_card_path |
POST /gridauth/card |
Issue a new pending card |
print_gridauth_grid_card_path |
GET /gridauth/card/print |
Printable view of the pending card |
activate_gridauth_grid_card_path |
POST /gridauth/card/activate |
Activate the pending card |
discard_gridauth_grid_card_path |
DELETE /gridauth/card/discard |
Discard the pending card |
gridauth_grid_card_path |
DELETE /gridauth/card |
Turn grid card authentication off |
How it works
Sign-in
-
SessionsController#createchecks the password, then callsstart_grid_card_challenge_for(user). - If the user has an active card, Gridauth resets the Rails session and keeps
a short-lived "pending sign-in" (the user id and a timestamp) in it. It
then redirects to the challenge. The user is not signed in yet: no
Sessionrecord, nosession_idcookie. - The challenge page shows an empty grid with the requested cells highlighted, plus one input per cell.
- If the answers are correct, Gridauth resets the session again, calls your
app's
start_new_session_for, and redirects toafter_authentication_url. That is the page the user first asked for, orroot_url. If the card is due for rotation, the user goes to the card page instead.
Enrollment and rotation
- On
/gridauth/cardthe user clicks Set up a grid card (or Replace grid card). A pending card is created. Its plaintext is kept only in the encrypted session cookie of the browser that issued it, so the user can view and print it. - The user activates it by entering the requested cells from the new card,
plus their current password (
require_password_for_changes). - Activation revokes any previous active card and removes the plaintext from the session. After that the card can't be shown again. A lost card is replaced by rotating, or reset by an administrator.
Configuration
config/initializers/gridauth.rb:
Gridauth.configure do |config|
config.user_class = "User"
config.parent_controller = "ApplicationController"
config.sign_in_route = :new_session_path
config.rows = 5 # rows 1..5
config.columns = 10 # columns A..J (max 26)
config.cell_length = 2 # characters per cell
config.alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
config.case_sensitive = false
config.challenge_size = 3 # cells per challenge
config.challenge_timeout = 5.minutes
config.max_failed_attempts = 5
config.lockout_period = 15.minutes
config.rotation_period = 180.days # nil to disable
config.rotate_after_uses = nil # e.g. 100
config.enforce_enrollment = false
config.enforce_rotation = false
config.policy_exempt_controllers = %w[sessions passwords]
config.require_password_for_changes = true
config.allow_disable = true
config.user_label_attribute = :email_address
# config.secret_key = Rails.application.credentials.dig(:gridauth, :secret_key)
endWith the defaults, each cell has 32² = 1,024 possible values, so guessing a 3-cell challenge has about a 1 in 10⁹ chance. Lockout and rate limiting cap how many guesses can be made.
Enforcement
With enforce_enrollment or enforce_rotation turned on,
Gridauth::Authentication adds a before_action. For a signed-in user who
needs to act, it redirects HTML requests to the card page and answers other
requests with 403 Forbidden. To exempt more controllers, add them to
policy_exempt_controllers or call this in the controller:
skip_grid_card_policy only: :showModel API
user.grid_card # active Gridauth::GridCard, or nil
user.pending_grid_card # issued but not yet activated
user.grid_card_enabled?
card, grid = user.issue_grid_card! # grid is the only plaintext copy
user.revoke_grid_cards! # e.g. after the user lost their card
card.issue_challenge! # => ["B3", "F1", "J4"]
card.verify_challenge("B3" => "7K", "F1" => "QF", "J4" => "M3") # => :success, :invalid or :locked
card.rotation_due?
card.rotation_due_at
card.activate!
card.revoke!You can use issue_grid_card! to issue cards from your own code, for example
to mail a printed card. Render grid with the
gridauth/shared/grid partial.
Administration
bin/rails "gridauth:revoke[user@example.com]" # lost card: back to password-only sign-in
bin/rails gridauth:due # list cards due for rotationCustomizing views
bin/rails generate gridauth:viewsThis copies the templates to app/views/gridauth. The pages render in your
application layout. Their small amount of CSS is scoped under .gridauth and
uses your Content Security Policy nonce, if you have one configured.
Security notes
- Cell digests are HMAC-SHA256 with a per-card salt. The key is derived from
secret_key_base, or set withconfig.secret_key. Changing that key invalidates every issued card. - A grid card is a "something you have" factor that can be photographed or
copied. Rotate cards regularly and keep
challenge_sizeat 3 or more. - Anyone who knows a user's password can trigger the lockout, which locks
that user out temporarily. Tune
max_failed_attemptsandlockout_periodto balance this against brute-force protection. - The
cellsparameter, which holds challenge answers, is added tofilter_parameters, so answers never reach the logs. - The plaintext of a pending card lives in the Rails session until activation. With the default cookie store, that cookie is encrypted.
Development
bundle install
bin/rails test
bin/rubocoptest/dummy is a Rails app built with bin/rails generate authentication and
bin/rails generate gridauth:install.
License
MIT