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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 85097e7cf7d67aa8d52b82e97626bb04f0f3aa8046ed7309849afad8f974be0e
4
+ data.tar.gz: d939a2dbfeca35a2b5d272dc8c72f51198539a4ac637e95ff78f027dea4f8b1d
5
+ SHA512:
6
+ metadata.gz: d78197f0d25364d893e93448cf78f152bf2e1457812fb5d2db76a923d5c92c339e080d48be1f4294e44ea83e0344307fb4811f1be0680f50edccdd30c6285fbc
7
+ data.tar.gz: fabc14d3a71e5d20db1516a01e1306563e47159eadad85e15e32f486c51c3703ded6bf7adaec3ef69a842f35187bb0042658f38f715302f3307a97a0c09be679
data/CHANGELOG.md ADDED
@@ -0,0 +1,6 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0
4
+
5
+ - Initial release: per-user grid cards, card rotation, sign-in challenge,
6
+ lockout, enrollment/rotation policies, install and views generators.
data/MIT-LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright TODO: Write your name
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,261 @@
1
+ # Gridauth
2
+
3
+ Grid card two-factor authentication for Rails 8+ applications that use the
4
+ built-in authentication generator (`bin/rails generate authentication`). It
5
+ does not use Devise.
6
+
7
+ A grid card is a small table of random characters, with lettered columns and
8
+ numbered rows, that the user prints or saves:
9
+
10
+ ```
11
+ A B C D E F G H I J
12
+ 1 7K QF M3 ...
13
+ 2 ...
14
+ ```
15
+
16
+ After the usual email and password form, the user is asked for the characters
17
+ in a few cells, for example **B3**, **F1** and **J4**. No session is created
18
+ until those answers are correct.
19
+
20
+ ## Features
21
+
22
+ - **One card per user**, generated with `SecureRandom`. The database stores
23
+ only per-cell HMAC-SHA256 digests, so the card can't be read back from it.
24
+ - **Enrollment**: the user issues a card, views or prints it, and activates it
25
+ by answering a challenge from the new card. This proves they saved it.
26
+ - **Rotation**: issuing a new card leaves the current one working until the
27
+ new one is activated. Then the old card is revoked. Cards can be due for
28
+ rotation by age (`rotation_period`), by number of sign-ins
29
+ (`rotate_after_uses`), or both. Rotation can be required (`enforce_rotation`).
30
+ - **Sign-in challenge** after the password step, with:
31
+ - challenge cells that stay fixed until answered correctly, so repeated
32
+ sign-ins can't be used to "shop" for cells an attacker knows
33
+ - constant-time comparison of every cell
34
+ - a lockout after repeated failures, plus Rails `rate_limit`
35
+ - a time limit for the second step
36
+ - `reset_session` on both steps to prevent session fixation
37
+ - **Optional enforcement**: every user can be required to enroll
38
+ (`enforce_enrollment`).
39
+ - **Generators**: the install generator wires everything up. The views
40
+ generator copies the views into your app so you can customize them.
41
+ - **I18n**: every message is in `config/locales/en.yml`.
42
+
43
+ ## Requirements
44
+
45
+ - Rails 8.0 or later
46
+ - Ruby 3.2 or later
47
+ - An app that already ran `bin/rails generate authentication` (`User`,
48
+ `Session`, `Current` and the `Authentication` concern)
49
+
50
+ ## Installation
51
+
52
+ ```ruby
53
+ # Gemfile
54
+ gem "gridauth"
55
+ ```
56
+
57
+ ```sh
58
+ bundle install
59
+ bin/rails generate gridauth:install
60
+ bin/rails db:migrate
61
+ ```
62
+
63
+ The install generator:
64
+
65
+ 1. creates `config/initializers/gridauth.rb`
66
+ 2. creates the `gridauth_grid_cards` migration
67
+ 3. adds `has_grid_card` to `app/models/user.rb`
68
+ 4. adds `include Gridauth::Authentication` to `ApplicationController`, after
69
+ `include Authentication`
70
+ 5. changes `SessionsController#create` from
71
+
72
+ ```ruby
73
+ start_new_session_for user
74
+ redirect_to after_authentication_url
75
+ ```
76
+
77
+ to
78
+
79
+ ```ruby
80
+ start_grid_card_challenge_for user
81
+ ```
82
+
83
+ 6. adds `gridauth_routes` to `config/routes.rb`
84
+
85
+ If your `SessionsController` has been customized, the generator prints a
86
+ message and you make step 5 by hand. Users without an active card still sign
87
+ in with just their password.
88
+
89
+ Then give signed-in users a link to their card settings:
90
+
91
+ ```erb
92
+ <%= link_to "Two-factor authentication", gridauth_grid_card_path %>
93
+ ```
94
+
95
+ ## Routes
96
+
97
+ `gridauth_routes` draws the routes straight into your app's route set (like
98
+ `devise_for`), so the pages render in your layout and your route helpers work
99
+ there. To use a different URL prefix, call `gridauth_routes path: "two-factor"`.
100
+
101
+ | Helper | Request | Purpose |
102
+ | --- | --- | --- |
103
+ | `new_gridauth_challenge_path` | `GET /gridauth/challenge/new` | Challenge form shown after the password step |
104
+ | `gridauth_challenge_path` | `POST /gridauth/challenge` | Check the answers and create the session |
105
+ | `gridauth_challenge_path` | `DELETE /gridauth/challenge` | Cancel the sign-in |
106
+ | `gridauth_grid_card_path` | `GET /gridauth/card` | Card status, enrollment and rotation |
107
+ | `gridauth_grid_card_path` | `POST /gridauth/card` | Issue a new pending card |
108
+ | `print_gridauth_grid_card_path` | `GET /gridauth/card/print` | Printable view of the pending card |
109
+ | `activate_gridauth_grid_card_path` | `POST /gridauth/card/activate` | Activate the pending card |
110
+ | `discard_gridauth_grid_card_path` | `DELETE /gridauth/card/discard` | Discard the pending card |
111
+ | `gridauth_grid_card_path` | `DELETE /gridauth/card` | Turn grid card authentication off |
112
+
113
+ ## How it works
114
+
115
+ ### Sign-in
116
+
117
+ 1. `SessionsController#create` checks the password, then calls
118
+ `start_grid_card_challenge_for(user)`.
119
+ 2. If the user has an active card, Gridauth resets the Rails session and keeps
120
+ a short-lived "pending sign-in" (the user id and a timestamp) in it. It
121
+ then redirects to the challenge. The user is **not** signed in yet: no
122
+ `Session` record, no `session_id` cookie.
123
+ 3. The challenge page shows an empty grid with the requested cells
124
+ highlighted, plus one input per cell.
125
+ 4. If the answers are correct, Gridauth resets the session again, calls your
126
+ app's `start_new_session_for`, and redirects to `after_authentication_url`.
127
+ That is the page the user first asked for, or `root_url`. If the card is
128
+ due for rotation, the user goes to the card page instead.
129
+
130
+ ### Enrollment and rotation
131
+
132
+ 1. On `/gridauth/card` the user clicks **Set up a grid card** (or **Replace
133
+ grid card**). A *pending* card is created. Its plaintext is kept only in
134
+ the encrypted session cookie of the browser that issued it, so the user can
135
+ view and print it.
136
+ 2. The user activates it by entering the requested cells from the new card,
137
+ plus their current password (`require_password_for_changes`).
138
+ 3. Activation revokes any previous active card and removes the plaintext from
139
+ the session. After that the card can't be shown again. A lost card is
140
+ replaced by rotating, or reset by an administrator.
141
+
142
+ ## Configuration
143
+
144
+ `config/initializers/gridauth.rb`:
145
+
146
+ ```ruby
147
+ Gridauth.configure do |config|
148
+ config.user_class = "User"
149
+ config.parent_controller = "ApplicationController"
150
+ config.sign_in_route = :new_session_path
151
+
152
+ config.rows = 5 # rows 1..5
153
+ config.columns = 10 # columns A..J (max 26)
154
+ config.cell_length = 2 # characters per cell
155
+ config.alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
156
+ config.case_sensitive = false
157
+
158
+ config.challenge_size = 3 # cells per challenge
159
+ config.challenge_timeout = 5.minutes
160
+
161
+ config.max_failed_attempts = 5
162
+ config.lockout_period = 15.minutes
163
+
164
+ config.rotation_period = 180.days # nil to disable
165
+ config.rotate_after_uses = nil # e.g. 100
166
+
167
+ config.enforce_enrollment = false
168
+ config.enforce_rotation = false
169
+ config.policy_exempt_controllers = %w[sessions passwords]
170
+
171
+ config.require_password_for_changes = true
172
+ config.allow_disable = true
173
+ config.user_label_attribute = :email_address
174
+
175
+ # config.secret_key = Rails.application.credentials.dig(:gridauth, :secret_key)
176
+ end
177
+ ```
178
+
179
+ With the defaults, each cell has 32² = 1,024 possible values, so guessing a
180
+ 3-cell challenge has about a 1 in 10⁹ chance. Lockout and rate limiting cap
181
+ how many guesses can be made.
182
+
183
+ ### Enforcement
184
+
185
+ With `enforce_enrollment` or `enforce_rotation` turned on,
186
+ `Gridauth::Authentication` adds a `before_action`. For a signed-in user who
187
+ needs to act, it redirects HTML requests to the card page and answers other
188
+ requests with `403 Forbidden`. To exempt more controllers, add them to
189
+ `policy_exempt_controllers` or call this in the controller:
190
+
191
+ ```ruby
192
+ skip_grid_card_policy only: :show
193
+ ```
194
+
195
+ ## Model API
196
+
197
+ ```ruby
198
+ user.grid_card # active Gridauth::GridCard, or nil
199
+ user.pending_grid_card # issued but not yet activated
200
+ user.grid_card_enabled?
201
+ card, grid = user.issue_grid_card! # grid is the only plaintext copy
202
+ user.revoke_grid_cards! # e.g. after the user lost their card
203
+
204
+ card.issue_challenge! # => ["B3", "F1", "J4"]
205
+ card.verify_challenge("B3" => "7K", "F1" => "QF", "J4" => "M3") # => :success, :invalid or :locked
206
+ card.rotation_due?
207
+ card.rotation_due_at
208
+ card.activate!
209
+ card.revoke!
210
+ ```
211
+
212
+ You can use `issue_grid_card!` to issue cards from your own code, for example
213
+ to mail a printed card. Render `grid` with the
214
+ `gridauth/shared/grid` partial.
215
+
216
+ ## Administration
217
+
218
+ ```sh
219
+ bin/rails "gridauth:revoke[user@example.com]" # lost card: back to password-only sign-in
220
+ bin/rails gridauth:due # list cards due for rotation
221
+ ```
222
+
223
+ ## Customizing views
224
+
225
+ ```sh
226
+ bin/rails generate gridauth:views
227
+ ```
228
+
229
+ This copies the templates to `app/views/gridauth`. The pages render in your
230
+ application layout. Their small amount of CSS is scoped under `.gridauth` and
231
+ uses your Content Security Policy nonce, if you have one configured.
232
+
233
+ ## Security notes
234
+
235
+ - Cell digests are HMAC-SHA256 with a per-card salt. The key is derived from
236
+ `secret_key_base`, or set with `config.secret_key`. **Changing that key
237
+ invalidates every issued card.**
238
+ - A grid card is a "something you have" factor that can be photographed or
239
+ copied. Rotate cards regularly and keep `challenge_size` at 3 or more.
240
+ - Anyone who knows a user's password can trigger the lockout, which locks
241
+ that user out temporarily. Tune `max_failed_attempts` and `lockout_period`
242
+ to balance this against brute-force protection.
243
+ - The `cells` parameter, which holds challenge answers, is added to
244
+ `filter_parameters`, so answers never reach the logs.
245
+ - The plaintext of a pending card lives in the Rails session until
246
+ activation. With the default cookie store, that cookie is encrypted.
247
+
248
+ ## Development
249
+
250
+ ```sh
251
+ bundle install
252
+ bin/rails test
253
+ bin/rubocop
254
+ ```
255
+
256
+ `test/dummy` is a Rails app built with `bin/rails generate authentication` and
257
+ `bin/rails generate gridauth:install`.
258
+
259
+ ## License
260
+
261
+ MIT
data/Rakefile ADDED
@@ -0,0 +1,6 @@
1
+ require "bundler/setup"
2
+
3
+ APP_RAKEFILE = File.expand_path("test/dummy/Rakefile", __dir__)
4
+ load "rails/tasks/engine.rake"
5
+
6
+ require "bundler/gem_tasks"
@@ -0,0 +1,27 @@
1
+ module Gridauth
2
+ class ApplicationController < Gridauth.config.parent_controller.to_s.constantize
3
+ helper Gridauth::GridHelper
4
+
5
+ skip_before_action :enforce_grid_card_policy, raise: false
6
+
7
+ private
8
+ def current_grid_card_user
9
+ Current.session&.user
10
+ end
11
+
12
+ def sign_in_url
13
+ public_send(Gridauth.config.sign_in_route)
14
+ end
15
+
16
+ # Hash of cell => answer submitted by the challenge form fields.
17
+ def challenge_answers
18
+ params.permit(cells: {}).fetch(:cells, {}).to_h
19
+ end
20
+
21
+ def password_confirmed?(user)
22
+ return true unless Gridauth.config.require_password_for_changes
23
+
24
+ params[:password].present? && user.authenticate(params[:password]).present?
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,63 @@
1
+ module Gridauth
2
+ # The second step of signing in: after SessionsController has checked the
3
+ # password, the user answers cells from their grid card here.
4
+ class ChallengesController < ApplicationController
5
+ allow_unauthenticated_access if respond_to?(:allow_unauthenticated_access)
6
+
7
+ rate_limit to: 10, within: 3.minutes, only: :create,
8
+ with: -> { redirect_to new_gridauth_challenge_path, alert: t("gridauth.challenges.rate_limited") }
9
+
10
+ before_action :load_pending_login, except: :destroy
11
+
12
+ def new
13
+ @cells = @card.issue_challenge! unless @card.locked?
14
+ end
15
+
16
+ def create
17
+ case @card.verify_challenge(challenge_answers)
18
+ when :success
19
+ complete_sign_in
20
+ when :locked
21
+ redirect_to new_gridauth_challenge_path, alert: t("gridauth.challenges.locked")
22
+ else
23
+ redirect_to new_gridauth_challenge_path, alert: t("gridauth.challenges.invalid")
24
+ end
25
+ end
26
+
27
+ def destroy
28
+ session.delete(Gridauth::PENDING_LOGIN_KEY)
29
+ redirect_to sign_in_url, status: :see_other
30
+ end
31
+
32
+ private
33
+ def load_pending_login
34
+ pending = session[Gridauth::PENDING_LOGIN_KEY]
35
+
36
+ unless pending.is_a?(Hash) && Time.zone.at(pending["started_at"].to_i) > Gridauth.config.challenge_timeout.ago
37
+ return abandon_sign_in(pending ? t("gridauth.challenges.expired") : nil)
38
+ end
39
+
40
+ @user = Gridauth.user_class.find_by(id: pending["user_id"])
41
+ @card = @user&.grid_card
42
+ abandon_sign_in(t("gridauth.challenges.expired")) unless @card
43
+ end
44
+
45
+ def abandon_sign_in(message)
46
+ session.delete(Gridauth::PENDING_LOGIN_KEY)
47
+ redirect_to sign_in_url, alert: message
48
+ end
49
+
50
+ def complete_sign_in
51
+ return_to = after_authentication_url
52
+ reset_session
53
+ start_new_session_for @user
54
+ @card.record_use!
55
+
56
+ if @card.rotation_due?
57
+ redirect_to gridauth_grid_card_path, notice: t("gridauth.policy.rotation_required")
58
+ else
59
+ redirect_to return_to
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,85 @@
1
+ module Gridauth
2
+ # Lets a signed-in user set up, print, rotate and (optionally) disable
3
+ # their grid card.
4
+ class GridCardsController < ApplicationController
5
+ before_action :set_user
6
+ before_action :set_pending_card, only: %i[ show print activate discard ]
7
+
8
+ def show
9
+ @card = @user.grid_card
10
+ @grid = new_card_grid
11
+ @cells = @pending_card.issue_challenge! if @grid && !@pending_card.locked?
12
+ end
13
+
14
+ # Issues a new pending card. If the user already has an active card it
15
+ # keeps working until the new one is activated (rotation).
16
+ def create
17
+ card, grid = @user.issue_grid_card!
18
+ session[Gridauth::NEW_CARD_KEY] = { "id" => card.id, "values" => grid.to_compact }
19
+ redirect_to gridauth_grid_card_path, notice: t("gridauth.grid_cards.issued")
20
+ end
21
+
22
+ def print
23
+ if (@grid = new_card_grid)
24
+ render layout: false
25
+ else
26
+ redirect_to gridauth_grid_card_path, alert: t("gridauth.grid_cards.unavailable")
27
+ end
28
+ end
29
+
30
+ # Confirms the user has saved the new card by answering a challenge from
31
+ # it, then makes it the active card and revokes the previous one.
32
+ def activate
33
+ return redirect_to(gridauth_grid_card_path, alert: t("gridauth.grid_cards.unavailable")) unless @pending_card
34
+ return redirect_to(gridauth_grid_card_path, alert: t("gridauth.grid_cards.wrong_password")) unless password_confirmed?(@user)
35
+
36
+ case @pending_card.verify_challenge(challenge_answers)
37
+ when :success
38
+ rotated = @user.grid_card_enabled?
39
+ @pending_card.activate!
40
+ session.delete(Gridauth::NEW_CARD_KEY)
41
+ redirect_to gridauth_grid_card_path, notice: t(rotated ? "gridauth.grid_cards.rotated" : "gridauth.grid_cards.activated")
42
+ when :locked
43
+ redirect_to gridauth_grid_card_path, alert: t("gridauth.challenges.locked")
44
+ else
45
+ redirect_to gridauth_grid_card_path, alert: t("gridauth.challenges.invalid")
46
+ end
47
+ end
48
+
49
+ def discard
50
+ @pending_card&.destroy!
51
+ session.delete(Gridauth::NEW_CARD_KEY)
52
+ redirect_to gridauth_grid_card_path, notice: t("gridauth.grid_cards.discarded"), status: :see_other
53
+ end
54
+
55
+ def destroy
56
+ if !Gridauth.config.allow_disable || Gridauth.config.enforce_enrollment
57
+ redirect_to gridauth_grid_card_path, alert: t("gridauth.grid_cards.disable_not_allowed"), status: :see_other
58
+ elsif !password_confirmed?(@user)
59
+ redirect_to gridauth_grid_card_path, alert: t("gridauth.grid_cards.wrong_password"), status: :see_other
60
+ else
61
+ @user.revoke_grid_cards!
62
+ session.delete(Gridauth::NEW_CARD_KEY)
63
+ redirect_to gridauth_grid_card_path, notice: t("gridauth.grid_cards.disabled"), status: :see_other
64
+ end
65
+ end
66
+
67
+ private
68
+ def set_user
69
+ @user = current_grid_card_user
70
+ end
71
+
72
+ def set_pending_card
73
+ @pending_card = @user.pending_grid_card
74
+ end
75
+
76
+ # The plaintext of the pending card, available only in the browser
77
+ # session that issued it.
78
+ def new_card_grid
79
+ stored = session[Gridauth::NEW_CARD_KEY]
80
+ return unless @pending_card && stored.is_a?(Hash) && stored["id"] == @pending_card.id
81
+
82
+ Grid.from_compact(stored["values"], rows: @pending_card.row_count, columns: @pending_card.column_count)
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,18 @@
1
+ module Gridauth
2
+ module GridHelper
3
+ # Accessible description of a cell key, e.g. "B3" => "column B, row 3".
4
+ def gridauth_cell_description(cell)
5
+ column, row = cell.to_s.match(/\A([A-Z])(\d+)\z/)&.captures
6
+ t("gridauth.cell_description", column:, row:)
7
+ end
8
+
9
+ def gridauth_user_label(user)
10
+ attribute = Gridauth.config.user_label_attribute
11
+ user.public_send(attribute) if attribute && user.respond_to?(attribute)
12
+ end
13
+
14
+ def gridauth_styles
15
+ render "gridauth/shared/styles"
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,5 @@
1
+ module Gridauth
2
+ class ApplicationRecord < ActiveRecord::Base
3
+ self.abstract_class = true
4
+ end
5
+ end
@@ -0,0 +1,154 @@
1
+ require "openssl"
2
+
3
+ module Gridauth
4
+ # A grid card belonging to a user. Only HMAC digests of the cell values are
5
+ # stored; the plaintext is shown once, when the card is issued.
6
+ #
7
+ # Lifecycle: pending (issued, awaiting confirmation) -> active -> revoked.
8
+ # A user has at most one active card; activating a new card revokes the old
9
+ # one, which is how rotation works.
10
+ class GridCard < ApplicationRecord
11
+ STATUSES = %w[pending active revoked].freeze
12
+ SERIAL_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789".chars.freeze
13
+
14
+ belongs_to :user, class_name: Gridauth.config.user_class, inverse_of: :grid_cards
15
+
16
+ serialize :cell_digests, coder: JSON
17
+
18
+ enum :status, STATUSES.index_by(&:itself), validate: true
19
+
20
+ validates :serial, presence: true, uniqueness: true
21
+ validates :row_count, :column_count, :cell_length, numericality: { only_integer: true, greater_than: 0 }
22
+ validates :salt, :cell_digests, presence: true
23
+
24
+ # Creates a pending card for `user` and returns `[card, grid]`.
25
+ def self.issue_for!(user, grid: Grid.generate)
26
+ transaction do
27
+ user.grid_cards.pending.delete_all
28
+
29
+ card = user.grid_cards.build(
30
+ serial: generate_serial,
31
+ row_count: grid.rows,
32
+ column_count: grid.columns,
33
+ cell_length: grid.values.each_value.first.length,
34
+ salt: SecureRandom.hex(16)
35
+ )
36
+ card.cell_digests = grid.values.to_h { |key, value| [ key, card.send(:digest_cell, key, value) ] }
37
+ card.save!
38
+
39
+ [ card, grid ]
40
+ end
41
+ end
42
+
43
+ def self.generate_serial
44
+ loop do
45
+ serial = Array.new(8) { SERIAL_ALPHABET[SecureRandom.random_number(SERIAL_ALPHABET.size)] }
46
+ .each_slice(4).map(&:join).join("-")
47
+ break serial unless exists?(serial:)
48
+ end
49
+ end
50
+
51
+ def column_labels = Grid.column_labels(column_count)
52
+ def row_labels = Grid.row_labels(row_count)
53
+ def cell_keys = Grid.cell_keys(row_count, column_count)
54
+
55
+ # Cells the user must answer next, e.g. ["B2", "F4", "J1"].
56
+ #
57
+ # A challenge stays the same until it has been answered correctly, so
58
+ # signing in over and over cannot be used to "shop" for cells an attacker
59
+ # happens to know.
60
+ def challenge_cells
61
+ self[:challenge_cells].to_s.split(",")
62
+ end
63
+
64
+ def issue_challenge!
65
+ with_lock do
66
+ if challenge_cells.empty?
67
+ size = Gridauth.config.challenge_size.clamp(1, cell_keys.size)
68
+ cells = cell_keys.sample(size, random: SecureRandom).sort_by { |key| cell_keys.index(key) }
69
+ update!(challenge_cells: cells.join(","))
70
+ end
71
+ end
72
+ challenge_cells
73
+ end
74
+
75
+ # Checks answers (a hash of cell => value) against the current challenge.
76
+ # Returns :success, :invalid or :locked. Wrong answers count towards the
77
+ # lockout; a correct answer resets the count and retires the challenge.
78
+ def verify_challenge(answers)
79
+ answers = (answers || {}).to_h.transform_keys { |key| key.to_s.upcase }
80
+
81
+ with_lock do
82
+ if locked?
83
+ :locked
84
+ elsif challenge_cells.empty?
85
+ :invalid
86
+ elsif challenge_cells.map { |cell| cell_matches?(cell, answers[cell]) }.all?
87
+ update!(challenge_cells: nil, failed_attempts: 0, locked_until: nil)
88
+ :success
89
+ else
90
+ register_failed_attempt
91
+ end
92
+ end
93
+ end
94
+
95
+ def locked?
96
+ locked_until.present? && locked_until.future?
97
+ end
98
+
99
+ def activate!
100
+ transaction do
101
+ user.grid_cards.active.where.not(id: id).find_each(&:revoke!)
102
+ update!(status: "active", activated_at: Time.current, challenge_cells: nil,
103
+ failed_attempts: 0, locked_until: nil, use_count: 0)
104
+ end
105
+ end
106
+
107
+ def revoke!
108
+ update!(status: "revoked", revoked_at: Time.current, challenge_cells: nil)
109
+ end
110
+
111
+ def record_use!
112
+ update!(use_count: use_count + 1, last_used_at: Time.current)
113
+ end
114
+
115
+ # When the card should be replaced based on age, if age-based rotation is on.
116
+ def rotation_due_at
117
+ activated_at + Gridauth.config.rotation_period if activated_at && Gridauth.config.rotation_period
118
+ end
119
+
120
+ def rotation_due?
121
+ return false unless active?
122
+
123
+ expired = rotation_due_at.present? && rotation_due_at <= Time.current
124
+ worn_out = Gridauth.config.rotate_after_uses.present? && use_count >= Gridauth.config.rotate_after_uses
125
+ expired || worn_out
126
+ end
127
+
128
+ private
129
+ def cell_matches?(cell, answer)
130
+ expected = cell_digests[cell]
131
+ expected.present? && ActiveSupport::SecurityUtils.secure_compare(expected, digest_cell(cell, answer))
132
+ end
133
+
134
+ def digest_cell(cell, value)
135
+ OpenSSL::HMAC.hexdigest("SHA256", Gridauth.config.secret_key, [ salt, cell, normalize(value) ].join(":"))
136
+ end
137
+
138
+ def normalize(value)
139
+ value = value.to_s.gsub(/\s+/, "")
140
+ Gridauth.config.case_sensitive ? value : value.upcase
141
+ end
142
+
143
+ def register_failed_attempt
144
+ attempts = failed_attempts + 1
145
+ if attempts >= Gridauth.config.max_failed_attempts
146
+ update!(failed_attempts: 0, locked_until: Time.current + Gridauth.config.lockout_period)
147
+ :locked
148
+ else
149
+ update!(failed_attempts: attempts)
150
+ :invalid
151
+ end
152
+ end
153
+ end
154
+ end
@@ -0,0 +1,20 @@
1
+ <%= gridauth_styles %>
2
+
3
+ <div class="gridauth">
4
+ <h1><%= t(".title") %></h1>
5
+
6
+ <% if @card.locked? %>
7
+ <p class="gridauth-alert"><%= t(".locked", time: l(@card.locked_until.in_time_zone, format: :short)) %></p>
8
+ <% else %>
9
+ <p><%= t(".intro", serial: @card.serial) %></p>
10
+
11
+ <%= render "gridauth/shared/grid", card: @card, highlight: @cells %>
12
+
13
+ <%= form_with url: gridauth_challenge_path, method: :post do %>
14
+ <%= render "gridauth/shared/challenge_fields", cells: @cells, card: @card %>
15
+ <%= submit_tag t(".submit") %>
16
+ <% end %>
17
+ <% end %>
18
+
19
+ <p><%= button_to t(".cancel"), gridauth_challenge_path, method: :delete %></p>
20
+ </div>