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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +6 -0
- data/MIT-LICENSE +20 -0
- data/README.md +261 -0
- data/Rakefile +6 -0
- data/app/controllers/gridauth/application_controller.rb +27 -0
- data/app/controllers/gridauth/challenges_controller.rb +63 -0
- data/app/controllers/gridauth/grid_cards_controller.rb +85 -0
- data/app/helpers/gridauth/grid_helper.rb +18 -0
- data/app/models/gridauth/application_record.rb +5 -0
- data/app/models/gridauth/grid_card.rb +154 -0
- data/app/views/gridauth/challenges/new.html.erb +20 -0
- data/app/views/gridauth/grid_cards/print.html.erb +32 -0
- data/app/views/gridauth/grid_cards/show.html.erb +89 -0
- data/app/views/gridauth/shared/_challenge_fields.html.erb +18 -0
- data/app/views/gridauth/shared/_grid.html.erb +22 -0
- data/app/views/gridauth/shared/_styles.html.erb +18 -0
- data/config/locales/en.yml +61 -0
- data/lib/generators/gridauth/install/install_generator.rb +86 -0
- data/lib/generators/gridauth/install/templates/create_gridauth_grid_cards.rb.tt +26 -0
- data/lib/generators/gridauth/install/templates/initializer.rb.tt +44 -0
- data/lib/generators/gridauth/views/views_generator.rb +14 -0
- data/lib/gridauth/authentication.rb +76 -0
- data/lib/gridauth/configuration.rb +89 -0
- data/lib/gridauth/engine.rb +14 -0
- data/lib/gridauth/grid.rb +63 -0
- data/lib/gridauth/model.rb +48 -0
- data/lib/gridauth/routing.rb +29 -0
- data/lib/gridauth/version.rb +3 -0
- data/lib/gridauth.rb +41 -0
- data/lib/tasks/gridauth_tasks.rake +20 -0
- metadata +86 -0
|
@@ -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
|