maquina-generators 0.7.0 → 0.8.1
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 +4 -4
- data/README.md +53 -0
- data/lib/generators/maquina/clave/templates/app/views/sessions/new.html.erb.tt +2 -0
- data/lib/generators/maquina/passkeys/USAGE +18 -0
- data/lib/generators/maquina/passkeys/passkeys_generator.rb +260 -0
- data/lib/generators/maquina/passkeys/templates/app/controllers/concerns/passkey_challenge.rb +42 -0
- data/lib/generators/maquina/passkeys/templates/app/controllers/concerns/passkey_reauthentication.rb +26 -0
- data/lib/generators/maquina/passkeys/templates/app/controllers/passkey_challenges_controller.rb +24 -0
- data/lib/generators/maquina/passkeys/templates/app/controllers/passkeys_controller.rb +68 -0
- data/lib/generators/maquina/passkeys/templates/app/controllers/session/passkey_challenges_controller.rb +18 -0
- data/lib/generators/maquina/passkeys/templates/app/controllers/session/passkeys_controller.rb +55 -0
- data/lib/generators/maquina/passkeys/templates/app/javascript/controllers/passkey_registration_controller.js +109 -0
- data/lib/generators/maquina/passkeys/templates/app/javascript/controllers/passkey_sign_in_controller.js +113 -0
- data/lib/generators/maquina/passkeys/templates/app/models/passkey.rb +36 -0
- data/lib/generators/maquina/passkeys/templates/app/views/passkeys/index.html.erb +77 -0
- data/lib/generators/maquina/passkeys/templates/app/views/sessions/_passkey.html.erb +23 -0
- data/lib/generators/maquina/passkeys/templates/config/initializers/webauthn.rb +23 -0
- data/lib/generators/maquina/passkeys/templates/config/locales/passkeys.en.yml +46 -0
- data/lib/generators/maquina/passkeys/templates/config/locales/passkeys.es.yml +46 -0
- data/lib/generators/maquina/passkeys/templates/migration_add_webauthn_id_to_users.rb.tt +6 -0
- data/lib/generators/maquina/passkeys/templates/migration_create_passkeys.rb.tt +20 -0
- data/lib/generators/maquina/passkeys/templates/test/integration/passkeys_test.rb +99 -0
- data/lib/generators/maquina/passkeys/templates/test/test_helpers/webauthn_test_helper.rb +38 -0
- data/lib/maquina_generators/version.rb +1 -1
- data/test/generators/maquina/clave_generator_test.rb +13 -2
- data/test/generators/maquina/passkeys_generator_test.rb +267 -0
- data/test/tmp/Gemfile +1 -13
- data/test/tmp/app/controllers/concerns/authentication.rb +2 -0
- data/test/tmp/app/controllers/concerns/passkey_challenge.rb +42 -0
- data/test/tmp/app/controllers/concerns/passkey_reauthentication.rb +26 -0
- data/test/tmp/app/controllers/passkey_challenges_controller.rb +24 -0
- data/test/tmp/app/controllers/passkeys_controller.rb +68 -0
- data/test/tmp/app/controllers/session/passkey_challenges_controller.rb +18 -0
- data/test/tmp/app/controllers/session/passkeys_controller.rb +55 -0
- data/test/tmp/app/javascript/controllers/passkey_registration_controller.js +109 -0
- data/test/tmp/app/javascript/controllers/passkey_sign_in_controller.js +113 -0
- data/test/tmp/app/models/email_verification.rb +2 -0
- data/test/tmp/app/models/passkey.rb +36 -0
- data/test/tmp/app/models/user.rb +47 -0
- data/test/tmp/app/views/passkeys/index.html.erb +77 -0
- data/test/tmp/app/views/sessions/_passkey.html.erb +23 -0
- data/test/tmp/app/views/sessions/new.html.erb +37 -0
- data/test/tmp/config/initializers/rack_attack.rb +9 -0
- data/test/tmp/config/initializers/webauthn.rb +23 -0
- data/test/tmp/config/locales/passkeys.en.yml +46 -0
- data/test/tmp/config/locales/passkeys.es.yml +46 -0
- data/test/tmp/config/routes.rb +7 -1
- data/test/tmp/db/migrate/20261014213621_add_webauthn_id_to_users.rb +6 -0
- data/test/tmp/db/migrate/20261014213622_create_passkeys.rb +20 -0
- data/test/tmp/test/integration/passkeys_test.rb +99 -0
- data/test/tmp/test/test_helper.rb +4 -0
- data/test/tmp/test/test_helpers/webauthn_test_helper.rb +38 -0
- metadata +49 -19
- data/test/tmp/.gitignore +0 -5
- data/test/tmp/.rubocop.yml +0 -11
- data/test/tmp/.standard.yml +0 -1
- data/test/tmp/Procfile.dev +0 -3
- data/test/tmp/README.md +0 -89
- data/test/tmp/app/controllers/home_controller.rb +0 -6
- data/test/tmp/app/javascript/application.js +0 -1
- data/test/tmp/app/views/home/index.html.erb +0 -204
- data/test/tmp/app/views/layouts/application.html.erb +0 -10
- data/test/tmp/config/application.rb +0 -15
- data/test/tmp/config/database.yml +0 -90
- data/test/tmp/config/database.yml.example +0 -90
- data/test/tmp/config/environments/development.rb +0 -8
- data/test/tmp/config/environments/production.rb +0 -6
- data/test/tmp/config/initializers/generators.rb +0 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7e7ab2221409345c84231d6908e18f5f14b265d32a10229f288f23d4c5a03006
|
|
4
|
+
data.tar.gz: b4966e9de4099d0664da18fd6ba2818b560770cdb2dab121a9f06265e38b4769
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: f7b0d031778c2aa4b8cc03079e08f049a79d499500dfe0bb8b44b5e94e7743afdfc0ea6d0625d97b0128c937628980c9eb5317eef5bfe9ea62d68cd25f3e0487
|
|
7
|
+
data.tar.gz: 2ccfde4751e49534fb7c36149c623ea8fd93849a8e26e860fae0566556f2832173f8f488b708dca6e23f8890e8a6b4243571e61c6811723a325c1535f2a5dde5
|
data/README.md
CHANGED
|
@@ -62,6 +62,59 @@ All generated code lives in your app -- edit it directly:
|
|
|
62
62
|
- **Email sender:** Edit `app/mailers/verification_mailer.rb`
|
|
63
63
|
- **Translations:** Edit `config/locales/clave.*.yml`
|
|
64
64
|
|
|
65
|
+
### Passkeys -- Passkey Sign-In for Clave
|
|
66
|
+
|
|
67
|
+
**Passkeys** adds WebAuthn passkeys as a second way to sign in, on top of Clave. People add passkeys from a `/passkeys` page and sign in with Face ID, a fingerprint or a device PIN, either with a button or straight from the email field's autofill. The email code stays as the way in without a passkey, so removing every passkey never locks anyone out.
|
|
68
|
+
|
|
69
|
+
The [`webauthn`](https://github.com/cedarcode/webauthn-ruby) gem handles only the cryptography (attestation and assertion parsing, signature, origin, challenge and user-verification checks). The flow itself, including controllers, model, views and JavaScript, is generated into your app. There is no JavaScript dependency: the Stimulus controllers use the browser's own `PublicKeyCredential.parseCreationOptionsFromJSON` / `parseRequestOptionsFromJSON` / `toJSON()`. The names (`passkeys` table, `credential_id`, `Session::PasskeysController`) follow the passkeys work proposed for Rails itself (rails/rails#57100).
|
|
70
|
+
|
|
71
|
+
#### What it generates
|
|
72
|
+
|
|
73
|
+
- **Gem:** `webauthn ~> 3.4`
|
|
74
|
+
- **Initializer:** `config/initializers/webauthn.rb`. The relying party is `localhost` in development; production reads `WEBAUTHN_RP_ID` / `WEBAUTHN_ORIGINS` (or `webauthn:` in credentials) and refuses to boot without them, except during the asset build.
|
|
75
|
+
- **Models:** `Passkey` (credential id, public key, sign count, transports, AAGUID, backup flags, last used) and `User#webauthn_id`, a random user handle created on first use
|
|
76
|
+
- **Sign-in:** `Session::PasskeyChallengesController` and `Session::PasskeysController`. These need no email (discoverable credentials, empty allow-list), require user verification, use each challenge once and expire it after 5 minutes, and sign in through Clave's `start_new_session_for`. An unknown credential gets a 404 so the browser can forget it (`signalUnknownCredential`).
|
|
77
|
+
- **Management:** `PasskeysController` and `PasskeyChallengesController` to add, rename and remove passkeys. Adding or removing a passkey needs a sign-in from the last 30 minutes (`PasskeyReauthentication::REAUTHENTICATE_AFTER`).
|
|
78
|
+
- **Views and JavaScript:** `passkeys/index`, a `sessions/_passkey` partial rendered on the sign-in page (and `autocomplete="username webauthn"` on its email field), plus `passkey_sign_in_controller.js` and `passkey_registration_controller.js`
|
|
79
|
+
- **Migrations:** `add_webauthn_id_to_users`, `create_passkeys`
|
|
80
|
+
- **Locale files:** English and Spanish
|
|
81
|
+
- **Rack::Attack:** a `passkey/ip` throttle when `config/initializers/rack_attack.rb` exists
|
|
82
|
+
- **Tests:** a `register_passkey` and `sign_in_with_passkey(user)` helper on top of `WebAuthn::FakeClient`, loaded from `test/test_helper.rb`, and `test/integration/passkeys_test.rb` covering the flow. The test creates users the way Clave's `User` does, so adjust its `setup` if your model differs.
|
|
83
|
+
|
|
84
|
+
#### Usage
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
rails g maquina:clave # first
|
|
88
|
+
rails g maquina:passkeys
|
|
89
|
+
rails db:migrate
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Then link to `passkeys_path` from your account or settings page. Anything the generator cannot place safely (a `User` model or sign-in page that no longer has Clave's shape) is listed after the install instead of being forced.
|
|
93
|
+
|
|
94
|
+
#### Options
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
rails g maquina:passkeys # Full install
|
|
98
|
+
rails g maquina:passkeys --skip-views # Endpoints only; bring your own UI
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
#### Production
|
|
102
|
+
|
|
103
|
+
Passkeys are bound to a domain, and changing `rp_id` later orphans every saved passkey. Set it before the first user adds one:
|
|
104
|
+
|
|
105
|
+
```bash
|
|
106
|
+
WEBAUTHN_RP_ID=example.com
|
|
107
|
+
WEBAUTHN_ORIGINS=https://example.com,https://app.example.com
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
`rp_id` can be a parent of the origins' host. To serve passkeys across different domains, publish `/.well-known/webauthn` (Related Origin Requests).
|
|
111
|
+
|
|
112
|
+
#### Testing
|
|
113
|
+
|
|
114
|
+
- **Integration tests:** `register_passkey` and `sign_in_with_passkey(user)` go through the real endpoints with `WebAuthn::FakeClient`.
|
|
115
|
+
- **In a browser:** Chrome's virtual authenticator works in DevTools (More tools > WebAuthn) or over CDP (`WebAuthn.addVirtualAuthenticator` with `ctap2`, `internal`, a resident key and user verification). It answers the sign-in page's autofill request on its own, so loading `/session/new` signs you in without a click. To test the button, make `PublicKeyCredential.isConditionalMediationAvailable` return `false`.
|
|
116
|
+
- **Dev server on another port:** set `WEBAUTHN_ORIGINS=http://localhost:<port>`.
|
|
117
|
+
|
|
65
118
|
### Registration -- Password-Based Authentication with Accounts
|
|
66
119
|
|
|
67
120
|
**Registration** generates a password-based authentication system with multi-tenant account support. It builds on top of the Rails 8 authentication generator, adding an Account model (tenant), user roles, and a registration flow.
|
|
@@ -30,11 +30,13 @@
|
|
|
30
30
|
<%%= form.submit t(".submit"), data: { component: "button", variant: "primary" }, class: "w-full px-4 py-3 text-base font-medium text-primary-foreground bg-primary hover:bg-primary/90 rounded-lg transition-colors cursor-pointer" %>
|
|
31
31
|
</div>
|
|
32
32
|
<%% end %>
|
|
33
|
+
<% unless options[:skip_registration] -%>
|
|
33
34
|
|
|
34
35
|
<p class="mt-6 text-center text-sm text-muted-foreground">
|
|
35
36
|
<%%= t(".no_account") %>
|
|
36
37
|
<%%= link_to t(".sign_up"), new_registration_path, class: "font-medium text-primary hover:text-primary/80" %>
|
|
37
38
|
</p>
|
|
39
|
+
<% end -%>
|
|
38
40
|
</div>
|
|
39
41
|
</div>
|
|
40
42
|
</div>
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
Description:
|
|
2
|
+
Adds passkey (WebAuthn) sign-in on top of Clave's email-code login. Needs
|
|
3
|
+
maquina:clave; the email code stays as the way in without a passkey.
|
|
4
|
+
|
|
5
|
+
Adds the webauthn gem, which only verifies the ceremonies; the flow is
|
|
6
|
+
generated into the app. Installs the Passkey model and a webauthn_id on
|
|
7
|
+
users, sign-in endpoints (POST /session/passkey_challenge and
|
|
8
|
+
/session/passkey) that ask for no email, a /passkeys page to add, rename
|
|
9
|
+
and remove passkeys (adding or removing needs a sign-in from the last 30
|
|
10
|
+
minutes), two Stimulus controllers that use the browser's own WebAuthn JSON
|
|
11
|
+
helpers (no JavaScript library), a passkey button and autofill on the
|
|
12
|
+
sign-in page, config/initializers/webauthn.rb, EN/ES locales, a
|
|
13
|
+
Rack::Attack throttle and a FakeClient test helper. Anything it cannot
|
|
14
|
+
place safely is listed after the install instead of being forced.
|
|
15
|
+
|
|
16
|
+
Examples:
|
|
17
|
+
rails g maquina:passkeys
|
|
18
|
+
rails g maquina:passkeys --skip-views # Endpoints only; bring your own UI
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
require "rails/generators"
|
|
2
|
+
require "rails/generators/migration"
|
|
3
|
+
|
|
4
|
+
module Maquina
|
|
5
|
+
module Generators
|
|
6
|
+
class PasskeysGenerator < Rails::Generators::Base
|
|
7
|
+
include Rails::Generators::Migration
|
|
8
|
+
|
|
9
|
+
source_root File.expand_path("templates", __dir__)
|
|
10
|
+
|
|
11
|
+
GEM_LINE = %(gem "webauthn", "~> 3.4")
|
|
12
|
+
USER_SESSIONS = /^ has_many :sessions, dependent: :destroy\n/
|
|
13
|
+
USER_PRIVATE = /^ private\n/
|
|
14
|
+
SIGN_IN_FORM_END = /^ <% end %>\n/
|
|
15
|
+
EMAIL_AUTOCOMPLETE = %(autocomplete: "email")
|
|
16
|
+
TEST_HELP = /^require "rails\/test_help"\n/
|
|
17
|
+
THROTTLE_LIMIT = 20
|
|
18
|
+
THROTTLE_PERIOD = "1.minute"
|
|
19
|
+
|
|
20
|
+
class_option :skip_views, type: :boolean, default: false,
|
|
21
|
+
desc: "Skip view templates and Stimulus controllers"
|
|
22
|
+
class_option :quiet, type: :boolean, default: false,
|
|
23
|
+
desc: "Suppress post-install instructions"
|
|
24
|
+
|
|
25
|
+
def self.next_migration_number(dirname)
|
|
26
|
+
current = Time.now.utc.strftime("%Y%m%d%H%M%S")
|
|
27
|
+
existing = Dir.glob("#{dirname}/[0-9]*_*.rb").map { |f| File.basename(f).split("_", 2).first }.max
|
|
28
|
+
(existing && existing >= current) ? existing.succ : current
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# 1. Passkeys sign people in through clave's sessions; the email code
|
|
32
|
+
# stays as the way to get in without one.
|
|
33
|
+
def require_clave
|
|
34
|
+
return if %w[app/models/email_verification.rb app/controllers/concerns/authentication.rb]
|
|
35
|
+
.all? { |path| File.exist?(File.join(destination_root, path)) }
|
|
36
|
+
|
|
37
|
+
raise Thor::Error, "maquina:passkeys adds passkeys to Clave's email-code sign-in. Run `rails g maquina:clave` first."
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# 2. The webauthn gem verifies the ceremonies; the flow stays in the app.
|
|
41
|
+
def add_gem
|
|
42
|
+
gemfile = File.join(destination_root, "Gemfile")
|
|
43
|
+
return unless File.exist?(gemfile)
|
|
44
|
+
return if File.read(gemfile).match?(/^\s*gem ["']webauthn["']/)
|
|
45
|
+
|
|
46
|
+
append_to_file "Gemfile", "\n#{GEM_LINE}\n"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def run_bundle_install
|
|
50
|
+
return unless rails_app?
|
|
51
|
+
|
|
52
|
+
Bundler.with_unbundled_env do
|
|
53
|
+
system("bundle install", chdir: destination_root)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# 3. Relying party
|
|
58
|
+
def create_initializer
|
|
59
|
+
copy_file "config/initializers/webauthn.rb", "config/initializers/webauthn.rb"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# 4. Models
|
|
63
|
+
def create_models
|
|
64
|
+
copy_file "app/models/passkey.rb", "app/models/passkey.rb"
|
|
65
|
+
|
|
66
|
+
path = "app/models/user.rb"
|
|
67
|
+
content = File.read(File.join(destination_root, path))
|
|
68
|
+
return if content.include?("has_many :passkeys")
|
|
69
|
+
|
|
70
|
+
if content.match?(USER_SESSIONS) && content.scan(USER_PRIVATE).one?
|
|
71
|
+
inject_into_file path, user_association, after: USER_SESSIONS
|
|
72
|
+
inject_into_file path, user_webauthn_id, before: USER_PRIVATE
|
|
73
|
+
else
|
|
74
|
+
@user_pending = true
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# 5. Controllers
|
|
79
|
+
def create_controllers
|
|
80
|
+
copy_file "app/controllers/concerns/passkey_challenge.rb", "app/controllers/concerns/passkey_challenge.rb"
|
|
81
|
+
copy_file "app/controllers/concerns/passkey_reauthentication.rb", "app/controllers/concerns/passkey_reauthentication.rb"
|
|
82
|
+
copy_file "app/controllers/session/passkey_challenges_controller.rb", "app/controllers/session/passkey_challenges_controller.rb"
|
|
83
|
+
copy_file "app/controllers/session/passkeys_controller.rb", "app/controllers/session/passkeys_controller.rb"
|
|
84
|
+
copy_file "app/controllers/passkey_challenges_controller.rb", "app/controllers/passkey_challenges_controller.rb"
|
|
85
|
+
copy_file "app/controllers/passkeys_controller.rb", "app/controllers/passkeys_controller.rb"
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# 6. Routes
|
|
89
|
+
def add_routes
|
|
90
|
+
return if File.read(File.join(destination_root, "config/routes.rb")).include?("generated by maquina:passkeys")
|
|
91
|
+
|
|
92
|
+
route <<~RUBY
|
|
93
|
+
# Passkeys (generated by maquina:passkeys)
|
|
94
|
+
resource :session, only: [] do
|
|
95
|
+
resource :passkey_challenge, only: :create, controller: "session/passkey_challenges"
|
|
96
|
+
resource :passkey, only: :create, controller: "session/passkeys"
|
|
97
|
+
end
|
|
98
|
+
resource :passkey_challenge, only: :create
|
|
99
|
+
resources :passkeys, only: [:index, :create, :update, :destroy]
|
|
100
|
+
RUBY
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# 7. Migrations
|
|
104
|
+
def add_migrations
|
|
105
|
+
migration_template "migration_add_webauthn_id_to_users.rb.tt", "db/migrate/add_webauthn_id_to_users.rb"
|
|
106
|
+
migration_template "migration_create_passkeys.rb.tt", "db/migrate/create_passkeys.rb"
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# 8. Views and Stimulus controllers
|
|
110
|
+
def create_views
|
|
111
|
+
return if options[:skip_views]
|
|
112
|
+
|
|
113
|
+
copy_file "app/views/passkeys/index.html.erb", "app/views/passkeys/index.html.erb"
|
|
114
|
+
copy_file "app/views/sessions/_passkey.html.erb", "app/views/sessions/_passkey.html.erb"
|
|
115
|
+
copy_file "app/javascript/controllers/passkey_sign_in_controller.js",
|
|
116
|
+
"app/javascript/controllers/passkey_sign_in_controller.js"
|
|
117
|
+
copy_file "app/javascript/controllers/passkey_registration_controller.js",
|
|
118
|
+
"app/javascript/controllers/passkey_registration_controller.js"
|
|
119
|
+
|
|
120
|
+
add_passkey_to_sign_in_page
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# 9. Locale files
|
|
124
|
+
def create_locale_files
|
|
125
|
+
copy_file "config/locales/passkeys.en.yml", "config/locales/passkeys.en.yml"
|
|
126
|
+
copy_file "config/locales/passkeys.es.yml", "config/locales/passkeys.es.yml"
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# 10. Test helper and the flow's integration test
|
|
130
|
+
def create_test_helper
|
|
131
|
+
copy_file "test/test_helpers/webauthn_test_helper.rb", "test/test_helpers/webauthn_test_helper.rb"
|
|
132
|
+
copy_file "test/integration/passkeys_test.rb", "test/integration/passkeys_test.rb"
|
|
133
|
+
|
|
134
|
+
path = File.join(destination_root, "test/test_helper.rb")
|
|
135
|
+
return unless File.exist?(path)
|
|
136
|
+
|
|
137
|
+
content = File.read(path)
|
|
138
|
+
return if content.include?(test_helper_require)
|
|
139
|
+
return @test_helper_pending = true unless content.match?(TEST_HELP)
|
|
140
|
+
|
|
141
|
+
inject_into_file "test/test_helper.rb", "#{test_helper_require}\n", after: TEST_HELP
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# 11. Rack::Attack: the sign-in ceremony gets its own login-style throttle.
|
|
145
|
+
def configure_rack_attack
|
|
146
|
+
path = "config/initializers/rack_attack.rb"
|
|
147
|
+
full_path = File.join(destination_root, path)
|
|
148
|
+
return @rack_attack_pending = true unless File.exist?(full_path)
|
|
149
|
+
return if File.read(full_path).include?(%("passkey/ip"))
|
|
150
|
+
|
|
151
|
+
append_to_file path, passkey_throttle
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# 12. Post-install message
|
|
155
|
+
def show_post_install
|
|
156
|
+
return if options[:quiet]
|
|
157
|
+
|
|
158
|
+
say ""
|
|
159
|
+
say "Passkeys have been installed!", :green
|
|
160
|
+
say ""
|
|
161
|
+
say "Next steps:", :yellow
|
|
162
|
+
say " 1. bin/rails db:migrate"
|
|
163
|
+
say " 2. Link to passkeys_path from your account or settings page"
|
|
164
|
+
say " 3. Visit /passkeys signed in, add a passkey, and sign in with it at /session/new"
|
|
165
|
+
say ""
|
|
166
|
+
say "Production: passkeys are bound to your domain. Set them before deploying:", :yellow
|
|
167
|
+
say " WEBAUTHN_RP_ID=example.com"
|
|
168
|
+
say " WEBAUTHN_ORIGINS=https://example.com"
|
|
169
|
+
say " (or webauthn: {rp_id:, origins:} in credentials; see config/initializers/webauthn.rb)"
|
|
170
|
+
say ""
|
|
171
|
+
if @user_pending
|
|
172
|
+
say "Add to app/models/user.rb:", :yellow
|
|
173
|
+
say user_association
|
|
174
|
+
say user_webauthn_id
|
|
175
|
+
end
|
|
176
|
+
if @sign_in_pending
|
|
177
|
+
say "Offer passkeys on the sign-in page (app/views/sessions/new.html.erb):", :yellow
|
|
178
|
+
say " - render the button after the email form: <%= render \"sessions/passkey\" %>"
|
|
179
|
+
say %( - let the email field suggest passkeys: autocomplete: "username webauthn")
|
|
180
|
+
say ""
|
|
181
|
+
end
|
|
182
|
+
if @test_helper_pending
|
|
183
|
+
say "Load the passkey test helper in test/test_helper.rb:", :yellow
|
|
184
|
+
say " #{test_helper_require}"
|
|
185
|
+
say ""
|
|
186
|
+
end
|
|
187
|
+
if @rack_attack_pending
|
|
188
|
+
say "Throttle passkey sign-in in config/initializers/rack_attack.rb:", :yellow
|
|
189
|
+
say passkey_throttle
|
|
190
|
+
say ""
|
|
191
|
+
end
|
|
192
|
+
say "Optional:", :yellow
|
|
193
|
+
say " - Serving more than one domain? Publish /.well-known/webauthn (Related Origin Requests)"
|
|
194
|
+
say " - Edit config/locales/passkeys.*.yml to customize messages"
|
|
195
|
+
say " - Integration tests: register_passkey and sign_in_with_passkey(user) (test/test_helpers/webauthn_test_helper.rb)"
|
|
196
|
+
say ""
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
private
|
|
200
|
+
|
|
201
|
+
def rails_app?
|
|
202
|
+
File.exist?(File.join(destination_root, "bin/rails"))
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def migration_version
|
|
206
|
+
"[#{Rails::VERSION::MAJOR}.#{Rails::VERSION::MINOR}]"
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# The button goes after clave's email form, and the email field offers
|
|
210
|
+
# saved passkeys in its autofill. A page that no longer has that shape is
|
|
211
|
+
# left alone and listed after the install.
|
|
212
|
+
def add_passkey_to_sign_in_page
|
|
213
|
+
path = "app/views/sessions/new.html.erb"
|
|
214
|
+
full_path = File.join(destination_root, path)
|
|
215
|
+
return @sign_in_pending = true unless File.exist?(full_path)
|
|
216
|
+
|
|
217
|
+
content = File.read(full_path)
|
|
218
|
+
return if content.include?(%(render "sessions/passkey"))
|
|
219
|
+
return @sign_in_pending = true unless content.scan(SIGN_IN_FORM_END).one? && content.include?(EMAIL_AUTOCOMPLETE)
|
|
220
|
+
|
|
221
|
+
inject_into_file path, %(\n <%= render "sessions/passkey" %>\n), after: SIGN_IN_FORM_END
|
|
222
|
+
gsub_file path, EMAIL_AUTOCOMPLETE, %(autocomplete: "username webauthn")
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def test_helper_require
|
|
226
|
+
%(require_relative "test_helpers/webauthn_test_helper")
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def user_association
|
|
230
|
+
" has_many :passkeys, dependent: :destroy\n"
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def user_webauthn_id
|
|
234
|
+
<<~RUBY.gsub(/^(?=.)/, " ")
|
|
235
|
+
# The WebAuthn user handle: random and stable, never the id or the email,
|
|
236
|
+
# since authenticators store it and hand it back on every sign-in.
|
|
237
|
+
def webauthn_id
|
|
238
|
+
super || begin
|
|
239
|
+
update_column(:webauthn_id, WebAuthn.generate_user_id)
|
|
240
|
+
super
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
RUBY
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def passkey_throttle
|
|
248
|
+
<<~RUBY
|
|
249
|
+
|
|
250
|
+
# Passkey sign-in (maquina:passkeys): the challenge and the assertion,
|
|
251
|
+
# counted like the email-code login above. Keyed on the address Rails
|
|
252
|
+
# resolves through trusted proxies, not the proxy hop in req.ip.
|
|
253
|
+
Rack::Attack.throttle("passkey/ip", limit: #{THROTTLE_LIMIT}, period: #{THROTTLE_PERIOD}) do |req|
|
|
254
|
+
(req.env["action_dispatch.remote_ip"]&.to_s || req.ip) if req.path.start_with?("/session/passkey") && req.post?
|
|
255
|
+
end
|
|
256
|
+
RUBY
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# One WebAuthn challenge per ceremony, kept in the session (generated by
|
|
2
|
+
# maquina:passkeys).
|
|
3
|
+
#
|
|
4
|
+
# The browser signs the challenge, so a stored one that could be used twice
|
|
5
|
+
# would let a captured response be replayed. take_passkey_challenge removes it
|
|
6
|
+
# before anything is verified, and a challenge older than CHALLENGE_TTL is
|
|
7
|
+
# refused. Registration and sign-in keep theirs under separate keys, so one
|
|
8
|
+
# ceremony never consumes the other's.
|
|
9
|
+
module PasskeyChallenge
|
|
10
|
+
extend ActiveSupport::Concern
|
|
11
|
+
|
|
12
|
+
CHALLENGE_TTL = 5.minutes
|
|
13
|
+
|
|
14
|
+
private
|
|
15
|
+
|
|
16
|
+
def store_passkey_challenge(purpose, challenge)
|
|
17
|
+
session[passkey_challenge_key(purpose)] = {
|
|
18
|
+
"challenge" => challenge,
|
|
19
|
+
"expires_at" => CHALLENGE_TTL.from_now.to_i
|
|
20
|
+
}
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def take_passkey_challenge(purpose)
|
|
24
|
+
stored = session.delete(passkey_challenge_key(purpose))
|
|
25
|
+
stored["challenge"] if stored && stored["expires_at"].to_i > Time.current.to_i
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def passkey_challenge_key(purpose)
|
|
29
|
+
:"passkey_#{purpose}_challenge"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# The browser's PublicKeyCredential#toJSON(). It is checked by signature, not
|
|
33
|
+
# assigned to a record, so only the shape is permitted here.
|
|
34
|
+
def passkey_credential_params
|
|
35
|
+
params.require(:credential).permit(
|
|
36
|
+
:id, :rawId, :type, :authenticatorAttachment,
|
|
37
|
+
response: [:clientDataJSON, :attestationObject, :authenticatorData, :signature, :userHandle,
|
|
38
|
+
:publicKey, :publicKeyAlgorithm, transports: []],
|
|
39
|
+
clientExtensionResults: {}
|
|
40
|
+
).to_h
|
|
41
|
+
end
|
|
42
|
+
end
|
data/lib/generators/maquina/passkeys/templates/app/controllers/concerns/passkey_reauthentication.rb
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# Adding or removing a passkey asks for a recent sign-in (generated by
|
|
2
|
+
# maquina:passkeys).
|
|
3
|
+
#
|
|
4
|
+
# A passkey added from a stolen session cookie would outlive the cookie, so
|
|
5
|
+
# a session older than REAUTHENTICATE_AFTER is ended and the person signs in
|
|
6
|
+
# again (email code or passkey), coming back to the passkeys page after.
|
|
7
|
+
module PasskeyReauthentication
|
|
8
|
+
extend ActiveSupport::Concern
|
|
9
|
+
|
|
10
|
+
REAUTHENTICATE_AFTER = 30.minutes
|
|
11
|
+
|
|
12
|
+
private
|
|
13
|
+
|
|
14
|
+
def require_recent_sign_in
|
|
15
|
+
return if Current.session.created_at.after?(REAUTHENTICATE_AFTER.ago)
|
|
16
|
+
|
|
17
|
+
terminate_session
|
|
18
|
+
session[:return_to_after_authenticating] = passkeys_url
|
|
19
|
+
flash[:alert] = t("flash.passkeys.reauthenticate")
|
|
20
|
+
|
|
21
|
+
respond_to do |format|
|
|
22
|
+
format.html { redirect_to new_session_path }
|
|
23
|
+
format.json { render json: {redirect_url: new_session_url}, status: :unauthorized }
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
data/lib/generators/maquina/passkeys/templates/app/controllers/passkey_challenges_controller.rb
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
class PasskeyChallengesController < ApplicationController
|
|
2
|
+
include PasskeyChallenge
|
|
3
|
+
include PasskeyReauthentication
|
|
4
|
+
|
|
5
|
+
before_action :require_recent_sign_in
|
|
6
|
+
|
|
7
|
+
# POST /passkey_challenge
|
|
8
|
+
#
|
|
9
|
+
# Options for navigator.credentials.create(). A discoverable credential
|
|
10
|
+
# (resident key) is what makes it a passkey: the device keeps the account,
|
|
11
|
+
# so sign-in needs no email. The user's existing passkeys are excluded so the
|
|
12
|
+
# same authenticator is not registered twice.
|
|
13
|
+
def create
|
|
14
|
+
user = Current.user
|
|
15
|
+
options = WebAuthn::Credential.options_for_create(
|
|
16
|
+
user: {id: user.webauthn_id, name: user.email_address, display_name: user.name.presence || user.email_address},
|
|
17
|
+
exclude: user.passkeys.pluck(:credential_id),
|
|
18
|
+
authenticator_selection: {resident_key: "required", user_verification: "required"}
|
|
19
|
+
)
|
|
20
|
+
store_passkey_challenge(:registration, options.challenge)
|
|
21
|
+
|
|
22
|
+
render json: options
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
class PasskeysController < ApplicationController
|
|
2
|
+
include PasskeyChallenge
|
|
3
|
+
include PasskeyReauthentication
|
|
4
|
+
|
|
5
|
+
before_action :require_recent_sign_in, only: %i[create destroy]
|
|
6
|
+
before_action :set_passkey, only: %i[update destroy]
|
|
7
|
+
|
|
8
|
+
# GET /passkeys
|
|
9
|
+
def index
|
|
10
|
+
@passkeys = Current.user.passkeys.order(:created_at)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# POST /passkeys
|
|
14
|
+
def create
|
|
15
|
+
challenge = take_passkey_challenge(:registration)
|
|
16
|
+
return render_failure(t("flash.passkeys.challenge_expired")) unless challenge
|
|
17
|
+
|
|
18
|
+
credential = WebAuthn::Credential.from_create(passkey_credential_params)
|
|
19
|
+
# Checks the challenge, the origin, the relying party and that the person
|
|
20
|
+
# was verified on the device, not just present.
|
|
21
|
+
credential.verify(challenge, user_verification: true)
|
|
22
|
+
|
|
23
|
+
aaguid = credential.response.authenticator_data.aaguid
|
|
24
|
+
Current.user.passkeys.create!(
|
|
25
|
+
credential_id: credential.id,
|
|
26
|
+
public_key: credential.public_key,
|
|
27
|
+
sign_count: credential.sign_count,
|
|
28
|
+
transports: credential.response.transports,
|
|
29
|
+
aaguid: aaguid,
|
|
30
|
+
backup_eligible: credential.backup_eligible?,
|
|
31
|
+
backed_up: credential.backed_up?,
|
|
32
|
+
name: Passkey.default_name(aaguid)
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
Rails.logger.info "[AUTH] Passkey added for #{Current.user.email_address} from #{request.remote_ip}"
|
|
36
|
+
flash[:notice] = t("flash.passkeys.created")
|
|
37
|
+
render json: {redirect_url: passkeys_url}, status: :created
|
|
38
|
+
rescue WebAuthn::Error, ActiveRecord::RecordInvalid => error
|
|
39
|
+
Rails.logger.info "[AUTH] Passkey registration failed (#{error.class.name.demodulize}) for #{Current.user.email_address}"
|
|
40
|
+
render_failure(t("flash.passkeys.registration_failed"))
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# PATCH /passkeys/:id
|
|
44
|
+
def update
|
|
45
|
+
if @passkey.update(params.expect(passkey: [:name]))
|
|
46
|
+
redirect_to passkeys_path, notice: t("flash.passkeys.updated"), status: :see_other
|
|
47
|
+
else
|
|
48
|
+
redirect_to passkeys_path, alert: @passkey.errors.full_messages.to_sentence, status: :see_other
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# DELETE /passkeys/:id
|
|
53
|
+
def destroy
|
|
54
|
+
@passkey.destroy
|
|
55
|
+
Rails.logger.info "[AUTH] Passkey removed for #{Current.user.email_address} from #{request.remote_ip}"
|
|
56
|
+
redirect_to passkeys_path, notice: t("flash.passkeys.destroyed"), status: :see_other
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
def set_passkey
|
|
62
|
+
@passkey = Current.user.passkeys.find(params[:id])
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def render_failure(message)
|
|
66
|
+
render json: {error: message}, status: :unprocessable_entity
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
class Session::PasskeyChallengesController < ApplicationController
|
|
2
|
+
include PasskeyChallenge
|
|
3
|
+
|
|
4
|
+
allow_unauthenticated_access
|
|
5
|
+
|
|
6
|
+
# POST /session/passkey_challenge
|
|
7
|
+
#
|
|
8
|
+
# Options for navigator.credentials.get(). No allowCredentials: the browser
|
|
9
|
+
# offers every passkey it holds for this site, so nobody types an email and
|
|
10
|
+
# the response says nothing about which accounts exist. It is a POST so a
|
|
11
|
+
# Turbo-cached page never reuses a challenge.
|
|
12
|
+
def create
|
|
13
|
+
options = WebAuthn::Credential.options_for_get(user_verification: "required")
|
|
14
|
+
store_passkey_challenge(:authentication, options.challenge)
|
|
15
|
+
|
|
16
|
+
render json: options
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
class Session::PasskeysController < ApplicationController
|
|
2
|
+
include PasskeyChallenge
|
|
3
|
+
|
|
4
|
+
allow_unauthenticated_access
|
|
5
|
+
rate_limit to: 10, within: 3.minutes, only: :create, with: -> { render json: {error: t("flash.general.rate_limited")}, status: :too_many_requests }
|
|
6
|
+
|
|
7
|
+
# POST /session/passkey
|
|
8
|
+
def create
|
|
9
|
+
challenge = take_passkey_challenge(:authentication)
|
|
10
|
+
return render_failure(t("flash.passkeys.challenge_expired")) unless challenge
|
|
11
|
+
|
|
12
|
+
credential = WebAuthn::Credential.from_get(passkey_credential_params)
|
|
13
|
+
passkey = Passkey.includes(:user).find_by(credential_id: credential.id)
|
|
14
|
+
|
|
15
|
+
# Deleted here but still saved on the device: the browser can be told to
|
|
16
|
+
# forget it (PublicKeyCredential.signalUnknownCredential).
|
|
17
|
+
unless passkey
|
|
18
|
+
Rails.logger.info "[AUTH] Passkey sign-in with an unknown credential from #{request.remote_ip}"
|
|
19
|
+
render json: {error: t("flash.passkeys.unknown"), credential_id: credential.id, rp_id: WebAuthn.configuration.rp_id},
|
|
20
|
+
status: :not_found
|
|
21
|
+
return
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
user = passkey.user
|
|
25
|
+
if user.blocked? || credential.user_handle != user.webauthn_id
|
|
26
|
+
Rails.logger.info "[AUTH] Passkey sign-in refused for #{user.email_address} from #{request.remote_ip}"
|
|
27
|
+
return render_failure(t("flash.passkeys.failed"))
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Checks the challenge, the origin, the signature with the stored public
|
|
31
|
+
# key, user verification (Face ID, fingerprint, device PIN) and that the
|
|
32
|
+
# signature counter moved forward.
|
|
33
|
+
credential.verify(challenge, public_key: passkey.public_key, sign_count: passkey.sign_count, user_verification: true)
|
|
34
|
+
passkey.record_use!(credential.sign_count)
|
|
35
|
+
|
|
36
|
+
Rails.logger.info "[AUTH] Successful passkey login for #{user.email_address} from #{request.remote_ip}"
|
|
37
|
+
start_new_session_for(user)
|
|
38
|
+
flash[:notice] = t("flash.sessions.create.success")
|
|
39
|
+
render json: {redirect_url: after_authentication_url}
|
|
40
|
+
rescue WebAuthn::SignCountVerificationError
|
|
41
|
+
# A valid signature with a counter that went backwards: possibly a cloned
|
|
42
|
+
# authenticator. Synced passkeys always report 0 and never land here.
|
|
43
|
+
Rails.logger.warn "[AUTH] Passkey sign count went backwards for #{passkey.user.email_address} from #{request.remote_ip}"
|
|
44
|
+
render_failure(t("flash.passkeys.failed"))
|
|
45
|
+
rescue WebAuthn::Error => error
|
|
46
|
+
Rails.logger.info "[AUTH] Passkey sign-in failed (#{error.class.name.demodulize}) from #{request.remote_ip}"
|
|
47
|
+
render_failure(t("flash.passkeys.failed"))
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def render_failure(message)
|
|
53
|
+
render json: {error: message}, status: :unprocessable_entity
|
|
54
|
+
end
|
|
55
|
+
end
|