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
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
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,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,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>
|