gridauth 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,32 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="robots" content="noindex">
6
+ <title><%= t(".title", serial: @pending_card.serial) %></title>
7
+ <%= gridauth_styles %>
8
+ <%= tag.style nonce: content_security_policy_nonce do %>
9
+ body { margin: 0; }
10
+ .gridauth-print { display: inline-block; margin: 1.5rem; padding: 1rem 1.25rem; border: 2px dashed #555; font-family: system-ui, sans-serif; }
11
+ .gridauth-print h1 { font-size: 1.1rem; margin: 0 0 0.5rem; }
12
+ @media print { .gridauth-no-print { display: none; } }
13
+ <% end %>
14
+ </head>
15
+ <body>
16
+ <div class="gridauth-print">
17
+ <h1><%= t(".title", serial: @pending_card.serial) %></h1>
18
+ <%= render "gridauth/shared/grid", card: @pending_card, grid: @grid %>
19
+ <p class="gridauth-card-meta">
20
+ <% if (label = gridauth_user_label(@user)) %>
21
+ <%= t(".issued_to") %>: <%= label %><br>
22
+ <% end %>
23
+ <%= t(".issued_at") %>: <%= l(@pending_card.created_at.in_time_zone.to_date, format: :long) %>
24
+ </p>
25
+ <p class="gridauth-card-meta"><%= t(".keep_safe") %></p>
26
+ </div>
27
+ <p class="gridauth-no-print"><button type="button" id="gridauth-print-button"><%= t(".print") %></button></p>
28
+ <%= javascript_tag nonce: true do %>
29
+ document.getElementById("gridauth-print-button").addEventListener("click", function () { window.print() })
30
+ <% end %>
31
+ </body>
32
+ </html>
@@ -0,0 +1,89 @@
1
+ <%= gridauth_styles %>
2
+
3
+ <div class="gridauth">
4
+ <h1><%= t(".title") %></h1>
5
+
6
+ <% if @grid %>
7
+ <section>
8
+ <h2><%= t(".new_title") %></h2>
9
+ <p><%= t(".new_intro") %></p>
10
+ <p class="gridauth-card-meta"><%= t(".serial") %>: <strong><%= @pending_card.serial %></strong></p>
11
+ <%= render "gridauth/shared/grid", card: @pending_card, grid: @grid %>
12
+ <p><%= link_to t(".print"), print_gridauth_grid_card_path, target: "_blank", rel: "noopener" %></p>
13
+ </section>
14
+
15
+ <section>
16
+ <h2><%= t(".activate_title") %></h2>
17
+ <% if @pending_card.locked? %>
18
+ <p class="gridauth-alert"><%= t(".locked", time: l(@pending_card.locked_until.in_time_zone, format: :short)) %></p>
19
+ <% else %>
20
+ <p><%= t(".activate_intro") %></p>
21
+ <%= form_with url: activate_gridauth_grid_card_path, method: :post do %>
22
+ <%= render "gridauth/shared/challenge_fields", cells: @cells, card: @pending_card %>
23
+ <% if Gridauth.config.require_password_for_changes %>
24
+ <p>
25
+ <%= label_tag :gridauth_activate_password, t(".password") %><br>
26
+ <%= password_field_tag :password, nil, id: :gridauth_activate_password, required: true, autocomplete: "current-password" %>
27
+ </p>
28
+ <% end %>
29
+ <%= submit_tag t(".activate") %>
30
+ <% end %>
31
+ <% end %>
32
+ <p><%= button_to t(".discard"), discard_gridauth_grid_card_path, method: :delete %></p>
33
+ </section>
34
+ <% elsif @pending_card %>
35
+ <section>
36
+ <h2><%= t(".lost_title") %></h2>
37
+ <p><%= t(".lost") %></p>
38
+ <%= button_to t(".reissue"), gridauth_grid_card_path, method: :post %>
39
+ </section>
40
+ <% end %>
41
+
42
+ <% if @card %>
43
+ <section>
44
+ <h2><%= t(".active_title") %></h2>
45
+ <dl>
46
+ <dt><%= t(".serial") %></dt>
47
+ <dd><%= @card.serial %></dd>
48
+ <dt><%= t(".activated_at") %></dt>
49
+ <dd><%= l(@card.activated_at.in_time_zone, format: :long) %></dd>
50
+ <dt><%= t(".last_used_at") %></dt>
51
+ <dd><%= @card.last_used_at ? l(@card.last_used_at.in_time_zone, format: :long) : t(".never") %></dd>
52
+ <dt><%= t(".use_count") %></dt>
53
+ <dd><%= @card.use_count %></dd>
54
+ <% if @card.rotation_due_at %>
55
+ <dt><%= t(".rotation_due_at") %></dt>
56
+ <dd><%= l(@card.rotation_due_at.in_time_zone.to_date, format: :long) %></dd>
57
+ <% end %>
58
+ </dl>
59
+ <% if @card.rotation_due? %>
60
+ <p class="gridauth-alert"><%= t(".rotation_due") %></p>
61
+ <% end %>
62
+ <% unless @grid %>
63
+ <p><%= t(".rotate_intro") %></p>
64
+ <%= button_to t(".rotate"), gridauth_grid_card_path, method: :post %>
65
+ <% end %>
66
+ </section>
67
+
68
+ <% if Gridauth.config.allow_disable && !Gridauth.config.enforce_enrollment %>
69
+ <section>
70
+ <h2><%= t(".disable_title") %></h2>
71
+ <p><%= t(".disable_intro") %></p>
72
+ <%= form_with url: gridauth_grid_card_path, method: :delete do %>
73
+ <% if Gridauth.config.require_password_for_changes %>
74
+ <p>
75
+ <%= label_tag :gridauth_disable_password, t(".password") %><br>
76
+ <%= password_field_tag :password, nil, id: :gridauth_disable_password, required: true, autocomplete: "current-password" %>
77
+ </p>
78
+ <% end %>
79
+ <%= submit_tag t(".disable") %>
80
+ <% end %>
81
+ </section>
82
+ <% end %>
83
+ <% elsif !@pending_card %>
84
+ <section>
85
+ <p><%= t(".none") %></p>
86
+ <%= button_to t(".issue"), gridauth_grid_card_path, method: :post %>
87
+ </section>
88
+ <% end %>
89
+ </div>
@@ -0,0 +1,18 @@
1
+ <%# locals: (cells:, card:) -%>
2
+ <div class="gridauth-fields">
3
+ <% cells.each_with_index do |cell, index| %>
4
+ <label class="gridauth-field">
5
+ <span aria-hidden="true"><%= cell %></span>
6
+ <%= text_field_tag "cells[#{cell}]", nil,
7
+ id: "gridauth_cell_#{cell}",
8
+ required: true,
9
+ autofocus: index.zero?,
10
+ autocomplete: "off",
11
+ autocapitalize: "characters",
12
+ spellcheck: false,
13
+ maxlength: card.cell_length,
14
+ size: card.cell_length + 1,
15
+ aria: { label: gridauth_cell_description(cell) } %>
16
+ </label>
17
+ <% end %>
18
+ </div>
@@ -0,0 +1,22 @@
1
+ <%# locals: (card:, grid: nil, highlight: []) -%>
2
+ <table class="gridauth-table" aria-label="<%= t("gridauth.grid_cards.print.title", serial: card.serial) %>">
3
+ <thead>
4
+ <tr>
5
+ <th scope="col"></th>
6
+ <% card.column_labels.each do |column| %>
7
+ <th scope="col"><%= column %></th>
8
+ <% end %>
9
+ </tr>
10
+ </thead>
11
+ <tbody>
12
+ <% card.row_labels.each do |row| %>
13
+ <tr>
14
+ <th scope="row"><%= row %></th>
15
+ <% card.column_labels.each do |column| %>
16
+ <% key = "#{column}#{row}" %>
17
+ <%= tag.td grid ? grid[key] : "", class: ("gridauth-highlight" if highlight.include?(key)) %>
18
+ <% end %>
19
+ </tr>
20
+ <% end %>
21
+ </tbody>
22
+ </table>
@@ -0,0 +1,18 @@
1
+ <%= tag.style nonce: content_security_policy_nonce do %>
2
+ .gridauth { max-width: 44rem; margin: 2rem auto; font-family: system-ui, sans-serif; line-height: 1.5; }
3
+ .gridauth section { margin: 1.5rem 0; padding: 1.25rem; border: 1px solid #d0d4da; border-radius: 0.5rem; }
4
+ .gridauth-table { border-collapse: collapse; font-family: ui-monospace, Menlo, Consolas, monospace; font-size: 1.05rem; }
5
+ .gridauth-table th, .gridauth-table td { border: 1px solid #9aa1ab; padding: 0.3rem 0.55rem; text-align: center; min-width: 2.2rem; }
6
+ .gridauth-table th { background: #eef0f3; font-weight: 600; }
7
+ .gridauth-table td.gridauth-highlight { background: #ffe58a; outline: 2px solid #b08800; }
8
+ .gridauth-card-meta { font-size: 0.9rem; color: #4a5160; }
9
+ .gridauth-fields { display: flex; flex-wrap: wrap; gap: 1rem; margin: 1rem 0; }
10
+ .gridauth-fields label { display: flex; flex-direction: column; font-weight: 600; }
11
+ .gridauth-fields input { font-family: ui-monospace, Menlo, Consolas, monospace; font-size: 1.25rem; width: 5rem; text-align: center; text-transform: uppercase; }
12
+ .gridauth-field input { margin-top: 0.25rem; }
13
+ .gridauth-actions { display: flex; gap: 0.75rem; align-items: center; flex-wrap: wrap; }
14
+ .gridauth-alert { color: #9b1c1c; }
15
+ .gridauth dl { display: grid; grid-template-columns: max-content 1fr; gap: 0.25rem 1rem; }
16
+ .gridauth dt { font-weight: 600; }
17
+ .gridauth dd { margin: 0; }
18
+ <% end %>
@@ -0,0 +1,61 @@
1
+ en:
2
+ gridauth:
3
+ cell_description: "column %{column}, row %{row}"
4
+ policy:
5
+ enrollment_required: "Set up a grid card to finish securing your account."
6
+ rotation_required: "Your grid card is due to be replaced. Please issue and activate a new card."
7
+ challenges:
8
+ new:
9
+ title: "Grid card verification"
10
+ intro: "Enter the characters printed on your grid card (serial %{serial}) in each of the highlighted cells."
11
+ locked: "Too many incorrect answers. Your grid card is locked until %{time}."
12
+ submit: "Verify"
13
+ cancel: "Cancel and sign in again"
14
+ invalid: "That doesn't match your grid card. Please try again."
15
+ locked: "Too many incorrect answers. Your grid card has been temporarily locked."
16
+ expired: "Your sign-in attempt expired. Please sign in again."
17
+ rate_limited: "Too many attempts. Try again later."
18
+ grid_cards:
19
+ issued: "Your new grid card is ready. Save or print it, then activate it below."
20
+ activated: "Grid card activated. You'll be asked for it each time you sign in."
21
+ rotated: "Your new grid card is active. The previous card no longer works."
22
+ discarded: "The new card was discarded."
23
+ disabled: "Grid card authentication has been turned off."
24
+ disable_not_allowed: "Grid card authentication can't be turned off for this account."
25
+ wrong_password: "Your password was incorrect."
26
+ unavailable: "That card can no longer be displayed. Issue a new card to continue."
27
+ show:
28
+ title: "Grid card"
29
+ none: "Protect your account with a grid card: a printed table of characters you'll be asked for whenever you sign in, in addition to your password."
30
+ issue: "Set up a grid card"
31
+ active_title: "Your grid card"
32
+ serial: "Serial"
33
+ activated_at: "Activated"
34
+ last_used_at: "Last used"
35
+ never: "Never"
36
+ use_count: "Sign-ins"
37
+ rotation_due_at: "Replace by"
38
+ rotation_due: "This card is due to be replaced."
39
+ rotate_intro: "Replacing your card regularly limits the damage if someone has seen it. Your current card keeps working until the new one is activated."
40
+ rotate: "Replace grid card"
41
+ new_title: "Your new grid card"
42
+ new_intro: "Print this card or store it somewhere safe. It is shown only now; we don't keep a readable copy."
43
+ print: "Print card"
44
+ activate_title: "Activate the new card"
45
+ activate_intro: "To confirm you've saved it, enter the characters from the new card in the highlighted cells."
46
+ password: "Current password"
47
+ activate: "Activate card"
48
+ discard: "Discard new card"
49
+ lost_title: "New card pending"
50
+ lost: "A new card was issued in another browser session and can't be shown here."
51
+ reissue: "Issue another card"
52
+ disable_title: "Turn off grid card authentication"
53
+ disable_intro: "You'll only need your password to sign in."
54
+ disable: "Turn off"
55
+ locked: "Too many incorrect answers. Try again after %{time}."
56
+ print:
57
+ title: "Grid card %{serial}"
58
+ issued_to: "Issued to"
59
+ issued_at: "Issued"
60
+ keep_safe: "Keep this card private. Never share it or enter it anywhere except when signing in."
61
+ print: "Print"
@@ -0,0 +1,86 @@
1
+ require "rails/generators"
2
+ require "rails/generators/active_record"
3
+
4
+ module Gridauth
5
+ module Generators
6
+ # Wires grid card 2FA into an app that uses `bin/rails generate authentication`.
7
+ class InstallGenerator < Rails::Generators::Base
8
+ include ActiveRecord::Generators::Migration
9
+
10
+ source_root File.expand_path("templates", __dir__)
11
+
12
+ class_option :user_class, type: :string, default: "User", desc: "Model that signs in"
13
+ class_option :path, type: :string, default: "gridauth", desc: "URL path prefix for the grid card pages"
14
+
15
+ def create_initializer
16
+ template "initializer.rb", "config/initializers/gridauth.rb"
17
+ end
18
+
19
+ def create_grid_cards_migration
20
+ migration_template "create_gridauth_grid_cards.rb", "db/migrate/create_gridauth_grid_cards.rb"
21
+ end
22
+
23
+ def add_to_user_model
24
+ path = File.join("app/models", "#{options[:user_class].underscore}.rb")
25
+ if File.exist?(File.join(destination_root, path))
26
+ inject_into_class path, options[:user_class].demodulize, " has_grid_card\n"
27
+ else
28
+ say_status :skip, "#{path} not found; add `has_grid_card` to your user model", :yellow
29
+ end
30
+ end
31
+
32
+ def add_to_application_controller
33
+ path = "app/controllers/application_controller.rb"
34
+ content = File.read(File.join(destination_root, path))
35
+ return if content.include?("Gridauth::Authentication")
36
+
37
+ if content.match?(/^\s*include Authentication\n/)
38
+ inject_into_file path, " include Gridauth::Authentication\n", after: /^\s*include Authentication\n/
39
+ else
40
+ say_status :skip, "Authentication concern not found in #{path}. Run `bin/rails generate authentication` first", :red
41
+ end
42
+ end
43
+
44
+ SIGN_IN_PATTERN = /start_new_session_for(\(| )user\)?\n\s*redirect_to after_authentication_url/
45
+
46
+ def update_sessions_controller
47
+ path = "app/controllers/sessions_controller.rb"
48
+ full_path = File.join(destination_root, path)
49
+ content = File.exist?(full_path) ? File.read(full_path) : ""
50
+
51
+ if content.match?(SIGN_IN_PATTERN)
52
+ gsub_file path, SIGN_IN_PATTERN, "start_grid_card_challenge_for user"
53
+ elsif !content.include?("start_grid_card_challenge_for")
54
+ say_status :manual, "In SessionsController#create, replace `start_new_session_for user` and " \
55
+ "`redirect_to after_authentication_url` with `start_grid_card_challenge_for user`", :yellow
56
+ end
57
+ end
58
+
59
+ def add_routes
60
+ route options[:path] == "gridauth" ? "gridauth_routes" : %(gridauth_routes path: "#{options[:path]}")
61
+ end
62
+
63
+ def show_next_steps
64
+ say <<~MSG
65
+
66
+ Grid card 2FA installed. Next:
67
+
68
+ 1. bin/rails db:migrate
69
+ 2. Link signed-in users to the card page: gridauth_grid_card_path
70
+ 3. Review config/initializers/gridauth.rb
71
+
72
+ MSG
73
+ end
74
+
75
+ private
76
+ def users_table
77
+ options[:user_class].tableize.tr("/", "_")
78
+ end
79
+
80
+ def primary_key_type
81
+ type = Rails.application&.config&.generators&.options&.dig(:active_record, :primary_key_type)
82
+ type ? ", type: :#{type}" : ""
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,26 @@
1
+ class CreateGridauthGridCards < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
2
+ def change
3
+ create_table :gridauth_grid_cards<%= primary_key_type.sub(", type:", ", id:") %> do |t|
4
+ t.references :user, null: false, foreign_key: { to_table: :<%= users_table %> }<%= primary_key_type %>
5
+ t.string :serial, null: false
6
+ t.string :status, null: false, default: "pending"
7
+ t.integer :row_count, null: false
8
+ t.integer :column_count, null: false
9
+ t.integer :cell_length, null: false
10
+ t.string :salt, null: false
11
+ t.text :cell_digests, null: false
12
+ t.string :challenge_cells
13
+ t.integer :failed_attempts, null: false, default: 0
14
+ t.datetime :locked_until
15
+ t.integer :use_count, null: false, default: 0
16
+ t.datetime :last_used_at
17
+ t.datetime :activated_at
18
+ t.datetime :revoked_at
19
+
20
+ t.timestamps
21
+ end
22
+
23
+ add_index :gridauth_grid_cards, :serial, unique: true
24
+ add_index :gridauth_grid_cards, [ :user_id, :status ]
25
+ end
26
+ end
@@ -0,0 +1,44 @@
1
+ Gridauth.configure do |config|
2
+ # Model that signs in, and the controller the engine's controllers inherit
3
+ # from (it must include the Rails `Authentication` concern).
4
+ config.user_class = "<%= options[:user_class] %>"
5
+ # config.parent_controller = "ApplicationController"
6
+ # config.sign_in_route = :new_session_path
7
+
8
+ # Card layout. Each cell holds `cell_length` characters from `alphabet`.
9
+ # config.rows = 5
10
+ # config.columns = 10
11
+ # config.cell_length = 2
12
+ # config.alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
13
+ # config.case_sensitive = false
14
+
15
+ # Cells asked for at each sign-in, and how long the user has to answer.
16
+ # config.challenge_size = 3
17
+ # config.challenge_timeout = 5.minutes
18
+
19
+ # Lock the card after this many consecutive wrong answers.
20
+ # config.max_failed_attempts = 5
21
+ # config.lockout_period = 15.minutes
22
+
23
+ # Rotation: a card is due for replacement after this long (nil disables)
24
+ # or after this many sign-ins (nil disables).
25
+ # config.rotation_period = 180.days
26
+ # config.rotate_after_uses = nil
27
+
28
+ # Policy. With enforce_enrollment every user must set up a card; with
29
+ # enforce_rotation the app is blocked until a due card is replaced.
30
+ # config.enforce_enrollment = false
31
+ # config.enforce_rotation = false
32
+ # config.policy_exempt_controllers = %w[sessions passwords]
33
+
34
+ # Require the current password to activate a card or turn 2FA off.
35
+ # config.require_password_for_changes = true
36
+ # config.allow_disable = true
37
+
38
+ # Printed on the card.
39
+ # config.user_label_attribute = :email_address
40
+
41
+ # Key for HMACing card cells. Defaults to one derived from secret_key_base;
42
+ # changing it invalidates every issued card.
43
+ # config.secret_key = Rails.application.credentials.dig(:gridauth, :secret_key)
44
+ end
@@ -0,0 +1,14 @@
1
+ require "rails/generators"
2
+
3
+ module Gridauth
4
+ module Generators
5
+ # Copies the engine's views into app/views/gridauth for customization.
6
+ class ViewsGenerator < Rails::Generators::Base
7
+ source_root File.expand_path("../../../../app/views/gridauth", __dir__)
8
+
9
+ def copy_views
10
+ directory ".", "app/views/gridauth"
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,76 @@
1
+ module Gridauth
2
+ # Include in ApplicationController after the Rails `Authentication` concern:
3
+ #
4
+ # class ApplicationController < ActionController::Base
5
+ # include Authentication
6
+ # include Gridauth::Authentication
7
+ # end
8
+ #
9
+ # and replace `start_new_session_for user` + `redirect_to after_authentication_url`
10
+ # in SessionsController#create with `start_grid_card_challenge_for user`.
11
+ module Authentication
12
+ extend ActiveSupport::Concern
13
+
14
+ included do
15
+ before_action :enforce_grid_card_policy
16
+ helper_method :grid_card_challenge_pending?
17
+ end
18
+
19
+ class_methods do
20
+ # Opts controllers or actions out of the enrollment/rotation policy.
21
+ def skip_grid_card_policy(**options)
22
+ skip_before_action :enforce_grid_card_policy, **options
23
+ end
24
+ end
25
+
26
+ private
27
+ # Called once the user's password has been verified. Users with an
28
+ # active grid card are sent to the challenge; no Session is created until
29
+ # they answer it. Users without a card are signed in directly.
30
+ def start_grid_card_challenge_for(user)
31
+ if user.respond_to?(:grid_card_enabled?) && user.grid_card_enabled?
32
+ return_to = session[:return_to_after_authenticating]
33
+ reset_session
34
+ session[:return_to_after_authenticating] = return_to if return_to
35
+ session[Gridauth::PENDING_LOGIN_KEY] = { "user_id" => user.id, "started_at" => Time.current.to_i }
36
+ redirect_to new_gridauth_challenge_path
37
+ else
38
+ start_new_session_for user
39
+ if Gridauth.config.enforce_enrollment
40
+ redirect_to gridauth_grid_card_path, notice: I18n.t("gridauth.policy.enrollment_required")
41
+ else
42
+ redirect_to after_authentication_url
43
+ end
44
+ end
45
+ end
46
+
47
+ def grid_card_challenge_pending?
48
+ session[Gridauth::PENDING_LOGIN_KEY].present?
49
+ end
50
+
51
+ def enforce_grid_card_policy
52
+ return if Gridauth.config.policy_exempt_controllers.include?(controller_path)
53
+ return unless (user = grid_card_policy_user)
54
+
55
+ if Gridauth.config.enforce_enrollment && !user.grid_card_enabled?
56
+ grid_card_policy_redirect I18n.t("gridauth.policy.enrollment_required")
57
+ elsif Gridauth.config.enforce_rotation && user.grid_card&.rotation_due?
58
+ grid_card_policy_redirect I18n.t("gridauth.policy.rotation_required")
59
+ end
60
+ end
61
+
62
+ def grid_card_policy_user
63
+ session = respond_to?(:resume_session, true) ? resume_session : Current.session
64
+ user = session&.user
65
+ user if user.respond_to?(:grid_card_enabled?)
66
+ end
67
+
68
+ def grid_card_policy_redirect(message)
69
+ if request.format.html?
70
+ redirect_to gridauth_grid_card_path, alert: message
71
+ else
72
+ head :forbidden
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,89 @@
1
+ module Gridauth
2
+ class Configuration
3
+ # Name of the model that represents an account (generated as `User` by
4
+ # `bin/rails generate authentication`).
5
+ attr_accessor :user_class
6
+
7
+ # Controller the engine's controllers inherit from. It must include the
8
+ # app's `Authentication` concern.
9
+ attr_accessor :parent_controller
10
+
11
+ # Route helper (on `main_app`) for the username and password form.
12
+ attr_accessor :sign_in_route
13
+
14
+ # Card layout: rows are numbered 1..rows, columns are lettered A..
15
+ attr_accessor :rows, :columns
16
+
17
+ # Number of characters printed in each cell and the characters used.
18
+ attr_accessor :cell_length, :alphabet
19
+
20
+ # When false, answers are upcased before comparison.
21
+ attr_accessor :case_sensitive
22
+
23
+ # Number of cells the user must answer on each challenge.
24
+ attr_accessor :challenge_size
25
+
26
+ # How long a user has, after entering a correct password, to answer the
27
+ # grid card challenge before having to sign in again.
28
+ attr_accessor :challenge_timeout
29
+
30
+ # Consecutive wrong answers before the card is locked, and for how long.
31
+ attr_accessor :max_failed_attempts, :lockout_period
32
+
33
+ # A card is due for rotation once it is this old (nil to disable) ...
34
+ attr_accessor :rotation_period
35
+ # ... or once it has been used for this many sign-ins (nil to disable).
36
+ attr_accessor :rotate_after_uses
37
+
38
+ # Require every user to set up a grid card before using the app.
39
+ attr_accessor :enforce_enrollment
40
+
41
+ # Block the app until a card that is due for rotation has been replaced.
42
+ attr_accessor :enforce_rotation
43
+
44
+ # Require the account password to activate a new card or disable 2FA.
45
+ attr_accessor :require_password_for_changes
46
+
47
+ # Allow users to turn grid card authentication off themselves.
48
+ attr_accessor :allow_disable
49
+
50
+ # Attribute of the user printed on the card.
51
+ attr_accessor :user_label_attribute
52
+
53
+ # Controller paths that are never redirected by the enrollment/rotation
54
+ # policy (sign out and password reset must keep working).
55
+ attr_accessor :policy_exempt_controllers
56
+
57
+ # Secret used to HMAC card cell values. Defaults to a key derived from
58
+ # the application's secret_key_base. Rotating it invalidates all cards.
59
+ attr_writer :secret_key
60
+
61
+ def initialize
62
+ @user_class = "User"
63
+ @parent_controller = "ApplicationController"
64
+ @sign_in_route = :new_session_path
65
+ @rows = 5
66
+ @columns = 10
67
+ @cell_length = 2
68
+ @alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
69
+ @case_sensitive = false
70
+ @challenge_size = 3
71
+ @challenge_timeout = 5.minutes
72
+ @max_failed_attempts = 5
73
+ @lockout_period = 15.minutes
74
+ @rotation_period = 180.days
75
+ @rotate_after_uses = nil
76
+ @enforce_enrollment = false
77
+ @enforce_rotation = false
78
+ @require_password_for_changes = true
79
+ @allow_disable = true
80
+ @user_label_attribute = :email_address
81
+ @policy_exempt_controllers = %w[sessions passwords]
82
+ @secret_key = nil
83
+ end
84
+
85
+ def secret_key
86
+ @secret_key ||= Rails.application.key_generator.generate_key("gridauth/grid-card-cells", 32)
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,14 @@
1
+ module Gridauth
2
+ class Engine < ::Rails::Engine
3
+ initializer "gridauth.model" do
4
+ ActiveSupport.on_load(:active_record) do
5
+ extend Gridauth::Model
6
+ end
7
+ end
8
+
9
+ # Keep grid card answers out of the logs.
10
+ initializer "gridauth.filter_parameters" do |app|
11
+ app.config.filter_parameters += [ /\Acells\z/ ]
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,63 @@
1
+ require "securerandom"
2
+
3
+ module Gridauth
4
+ # The plaintext contents of a grid card. Instances only exist briefly while
5
+ # a new card is shown to its owner; the database stores HMAC digests.
6
+ class Grid
7
+ MAX_COLUMNS = 26
8
+
9
+ attr_reader :rows, :columns, :values
10
+
11
+ def self.generate(rows: Gridauth.config.rows, columns: Gridauth.config.columns,
12
+ cell_length: Gridauth.config.cell_length, alphabet: Gridauth.config.alphabet)
13
+ chars = alphabet.chars.uniq
14
+ values = cell_keys(rows, columns).index_with do
15
+ Array.new(cell_length) { chars[SecureRandom.random_number(chars.size)] }.join
16
+ end
17
+ new(rows:, columns:, values:)
18
+ end
19
+
20
+ # Rebuilds a grid from #to_compact output.
21
+ def self.from_compact(string, rows:, columns:)
22
+ cell_length = string.length / (rows * columns)
23
+ values = cell_keys(rows, columns).each_with_index.to_h do |key, index|
24
+ [ key, string[index * cell_length, cell_length] ]
25
+ end
26
+ new(rows:, columns:, values:)
27
+ end
28
+
29
+ def self.column_labels(columns)
30
+ raise ArgumentError, "a grid card can have at most #{MAX_COLUMNS} columns" if columns > MAX_COLUMNS
31
+
32
+ ("A"..).first(columns)
33
+ end
34
+
35
+ def self.row_labels(rows)
36
+ (1..rows).map(&:to_s)
37
+ end
38
+
39
+ # Cell keys in row-major order, e.g. ["A1", "B1", ..., "A2", ...].
40
+ def self.cell_keys(rows, columns)
41
+ row_labels(rows).flat_map { |row| column_labels(columns).map { |column| "#{column}#{row}" } }
42
+ end
43
+
44
+ def initialize(rows:, columns:, values:)
45
+ @rows = rows
46
+ @columns = columns
47
+ @values = values
48
+ end
49
+
50
+ def column_labels = self.class.column_labels(columns)
51
+ def row_labels = self.class.row_labels(rows)
52
+ def cell_keys = self.class.cell_keys(rows, columns)
53
+
54
+ def [](key)
55
+ values[key.to_s.upcase]
56
+ end
57
+
58
+ # Compact form small enough to keep in the (encrypted) session cookie.
59
+ def to_compact
60
+ cell_keys.map { |key| values.fetch(key) }.join
61
+ end
62
+ end
63
+ end