rails_authentication 0.4.0 → 0.5.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 +4 -4
- data/README.md +63 -8
- data/lib/generators/authentication/authentication_generator.rb +15 -0
- data/lib/generators/authentication/features/confirmable.rb +13 -12
- data/lib/generators/authentication/features/invitable.rb +14 -13
- data/lib/generators/authentication/features/lockable.rb +13 -12
- data/lib/generators/authentication/features/magic_link.rb +13 -12
- data/lib/generators/authentication/features/ott.rb +13 -12
- data/lib/generators/authentication/features/passkey.rb +36 -0
- data/lib/generators/authentication/features/recoverable.rb +13 -12
- data/lib/generators/authentication/features/registerable.rb +7 -6
- data/lib/generators/authentication/features/rememberable.rb +6 -5
- data/lib/generators/authentication/features/timeoutable.rb +7 -6
- data/lib/generators/authentication/features/trackable.rb +7 -6
- data/lib/generators/authentication/features/validatable.rb +5 -4
- data/lib/generators/authentication/templates/app/controllers/passkey_sessions_controller.rb.tt +68 -0
- data/lib/generators/authentication/templates/app/controllers/sessions_controller.rb.tt +4 -0
- data/lib/generators/authentication/templates/app/controllers/webauthn_credentials_controller.rb.tt +43 -0
- data/lib/generators/authentication/templates/app/models/concerns/confirmable_concern.rb.tt +18 -17
- data/lib/generators/authentication/templates/app/models/concerns/passkey_concern.rb.tt +19 -0
- data/lib/generators/authentication/templates/app/models/webauthn_credential.rb.tt +6 -0
- data/lib/generators/authentication/templates/app/views/registrations/edit.html.erb.tt +4 -0
- data/lib/generators/authentication/templates/app/views/sessions/new.html.erb.tt +65 -0
- data/lib/generators/authentication/templates/app/views/webauthn_credentials/index.html.erb.tt +19 -0
- data/lib/generators/authentication/templates/app/views/webauthn_credentials/new.html.erb.tt +71 -0
- data/lib/generators/authentication/templates/config/initializers/webauthn.rb.tt +11 -0
- data/lib/generators/authentication/templates/db/migrate/add_webauthn_id_to_users.rb.tt +7 -0
- data/lib/generators/authentication/templates/db/migrate/create_webauthn_credentials.rb.tt +15 -0
- data/lib/rails_authentication/version.rb +1 -1
- metadata +11 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6369064c5074eb057abc1ed169b7efbe7ae0758730dc6c764b86fee19f96b0d3
|
|
4
|
+
data.tar.gz: 5bdc5cb01eb60fed2449f0a1544d29102c79f917faf345f86a1a86bca6b6adc6
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: da49badfb4b8c58c2525329479154961cb10cbd6b6d683a4dbc56171198e9ad1d1f31d097455f106d47d19332a14391ee0e4370394aa23535e85b47be6ebf0f8
|
|
7
|
+
data.tar.gz: 4407fbc0f0c7daafc4324fb1e0ba7c366c4c810fa81b579bf464fd2f2e31b7d5e3da6c6e8741b189e0399a28660b4e0a728d80b1bfec63282eee3f8c0d204756
|
data/README.md
CHANGED
|
@@ -19,6 +19,7 @@ gem extends that same command to also install:
|
|
|
19
19
|
| **Invitable** | Invite users by email; blocks sign-in until they accept by choosing their own password |
|
|
20
20
|
| **MagicLink** (opt-in) | Passwordless magic link sign-in via email — DB-backed, single-use, expiring tokens |
|
|
21
21
|
| **Ott** (opt-in) | Passwordless sign-in via an emailed 6-digit one-time code — replaces the password sign-in form |
|
|
22
|
+
| **Passkey** (opt-in) | WebAuthn passkey sign-in alongside the password form — usernameless, no email required |
|
|
22
23
|
|
|
23
24
|
Everything is **generated into your app** as plain, readable code — controllers, views, mailers,
|
|
24
25
|
migrations, and one model concern per feature. There is no runtime dependency on this gem: after
|
|
@@ -57,12 +58,13 @@ Available flags: `--skip-confirmable`, `--skip-recoverable`, `--skip-registerabl
|
|
|
57
58
|
`--skip-lockable`, `--skip-invitable`, and `--reconfirmable` (Confirmable: postpone email address
|
|
58
59
|
changes until the new address is confirmed, via an `unconfirmed_email` column).
|
|
59
60
|
|
|
60
|
-
MagicLink and
|
|
61
|
-
them:
|
|
61
|
+
MagicLink, Ott, and Passkey are **opt-in** features — they change the sign-in UX, so you have to
|
|
62
|
+
ask for them:
|
|
62
63
|
|
|
63
64
|
```sh
|
|
64
65
|
bin/rails generate authentication --magic-link
|
|
65
66
|
bin/rails generate authentication --ott
|
|
67
|
+
bin/rails generate authentication --passkey
|
|
66
68
|
```
|
|
67
69
|
|
|
68
70
|
MagicLink adds a "Sign in with magic link" link to the sign-in page, leading to an email-only
|
|
@@ -79,8 +81,16 @@ and are voided after 5 wrong entries (`OttConcern::OTT_MAX_ATTEMPTS`). The passw
|
|
|
79
81
|
(SessionsController#create, Recoverable, Registerable) stays generated and functional — only the
|
|
80
82
|
sign-in UI changes.
|
|
81
83
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
+
Passkey adds a "Sign in with a passkey" button to the sign-in page (no email field — the
|
|
85
|
+
browser's own discoverable-credential picker takes its place) plus an authenticated
|
|
86
|
+
`/webauthn_credentials` section where a signed-in user can register and remove passkeys (linked
|
|
87
|
+
from the account page when Registerable is enabled). Unlike every other feature here, this one
|
|
88
|
+
brings a real runtime dependency: the generator adds `gem "webauthn"` to your Gemfile for you (with
|
|
89
|
+
a reminder to run `bundle install`), since ceremony verification is delegated to it. A user can
|
|
90
|
+
register any number of passkeys, each stored as its own row in `webauthn_credentials`.
|
|
91
|
+
|
|
92
|
+
All three flows honor the other enabled features: locked, unconfirmed, or invitation-pending
|
|
93
|
+
accounts still can't sign in, and Trackable records the attempt.
|
|
84
94
|
|
|
85
95
|
Each feature adds a single `include <Feature>Concern` line to `app/models/user.rb`; all of its
|
|
86
96
|
model behavior lives in `app/models/concerns/<feature>_concern.rb`. Tunables are plain constants in
|
|
@@ -98,6 +108,8 @@ resources :unlocks, only: %i[ new create show ], param: :token
|
|
|
98
108
|
resources :invitations, only: %i[ new create edit update ], param: :token
|
|
99
109
|
resources :magic_links, only: %i[ new create show ], param: :token # with --magic-link
|
|
100
110
|
resource :ott, only: %i[ create edit update ] # with --ott
|
|
111
|
+
resources :webauthn_credentials, only: %i[ index new create destroy ] # with --passkey
|
|
112
|
+
resource :passkey_session, only: %i[ create ] # with --passkey
|
|
101
113
|
resource :session # from the base generator
|
|
102
114
|
resources :passwords, param: :token # from the base generator
|
|
103
115
|
```
|
|
@@ -114,10 +126,6 @@ resources :passwords, param: :token # from the base generator
|
|
|
114
126
|
sessions view with versions tailored to the features you selected. Run the generator once, up
|
|
115
127
|
front — re-running it after you've customized those files will prompt to overwrite them.
|
|
116
128
|
|
|
117
|
-
## Future Plans
|
|
118
|
-
|
|
119
|
-
- Passkey
|
|
120
|
-
|
|
121
129
|
## Development
|
|
122
130
|
|
|
123
131
|
```sh
|
|
@@ -131,6 +139,53 @@ bundle exec rspec spec/generators/lockable_spec.rb:12 # a single example
|
|
|
131
139
|
`spec/dummy` is generated (and gitignored), never hand-maintained, so the request specs always
|
|
132
140
|
exercise exactly what the templates produce.
|
|
133
141
|
|
|
142
|
+
To run the app using the dummy app generated in `spec/dummy`, run `bin/dev`
|
|
143
|
+
|
|
144
|
+
## Appraisal
|
|
145
|
+
|
|
146
|
+
Install Appraisal dependencies or when a new Appraisal is added, generate the .gemfile
|
|
147
|
+
|
|
148
|
+
```sh
|
|
149
|
+
bundle exec appraisal generate-install
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
Run tests with Appraisal
|
|
153
|
+
|
|
154
|
+
```sh
|
|
155
|
+
bin/appraisal
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
Run a specific Appraisal test
|
|
159
|
+
|
|
160
|
+
```sh
|
|
161
|
+
# rails-8.0-json-2
|
|
162
|
+
bundle exec appraisal rails-8.0-json-2 rake spec:generators
|
|
163
|
+
bundle exec appraisal rails-8.0-json-2 rake dummy:prepare
|
|
164
|
+
bundle exec appraisal rails-8.0-json-2 rake spec:requests
|
|
165
|
+
# rails-8.0-json-3
|
|
166
|
+
bundle exec appraisal rails-8.0-json-3 rake spec:generators
|
|
167
|
+
bundle exec appraisal rails-8.0-json-3 rake dummy:prepare
|
|
168
|
+
bundle exec appraisal rails-8.0-json-3 rake spec:requests
|
|
169
|
+
# rails-8.1-json-2
|
|
170
|
+
bundle exec appraisal rails-8.1-json-2 rake spec:generators
|
|
171
|
+
bundle exec appraisal rails-8.1-json-2 rake dummy:prepare
|
|
172
|
+
bundle exec appraisal rails-8.1-json-2 rake spec:requests
|
|
173
|
+
# rails-8.1-json-3
|
|
174
|
+
bundle exec appraisal rails-8.1-json-3 rake spec:generators
|
|
175
|
+
bundle exec appraisal rails-8.1-json-3 rake dummy:prepare
|
|
176
|
+
bundle exec appraisal rails-8.1-json-3 rake spec:requests
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
## json 3.0 gem compatibility
|
|
180
|
+
|
|
181
|
+
`json` 3.0 gem removed the `quirks_mode:` keyword and made `JSON.generate`/`JSON.parse`'s second argument keyword-only. Rails' `ActiveSupport::JSON` still calls both with `quirks_mode: true` positionally, so every
|
|
182
|
+
cookie/session read or write throws `ArgumentError` under json 3. `bin/prepare_dummy`
|
|
183
|
+
writes `spec/dummy/config/initializers/json_quirks_mode_compat.rb`, which prepends a shim onto
|
|
184
|
+
`JSON.generate`/`.parse` that strips `quirks_mode` and re-passes remaining options via `**opts`
|
|
185
|
+
(so it works whether the resolved `JSON.parse` still takes a positional options hash, json 2.x,
|
|
186
|
+
or keywords only, json 3.x). It no-ops under json 2.x (`Gem::Version.new(::JSON::VERSION) >=
|
|
187
|
+
Gem::Version.new("3.0")` gates it) and is safe to delete once Rails or json fixes this upstream.
|
|
188
|
+
|
|
134
189
|
## License
|
|
135
190
|
|
|
136
191
|
MIT
|
|
@@ -13,6 +13,7 @@ require_relative "features/lockable"
|
|
|
13
13
|
require_relative "features/invitable"
|
|
14
14
|
require_relative "features/magic_link"
|
|
15
15
|
require_relative "features/ott"
|
|
16
|
+
require_relative "features/passkey"
|
|
16
17
|
|
|
17
18
|
module RailsAuthentication
|
|
18
19
|
module Generators
|
|
@@ -46,6 +47,7 @@ module RailsAuthentication
|
|
|
46
47
|
include Features::Invitable
|
|
47
48
|
include Features::MagicLink
|
|
48
49
|
include Features::Ott
|
|
50
|
+
include Features::Passkey
|
|
49
51
|
|
|
50
52
|
source_root File.expand_path("templates", __dir__)
|
|
51
53
|
|
|
@@ -68,6 +70,11 @@ module RailsAuthentication
|
|
|
68
70
|
class_option :ott, type: :boolean, default: false,
|
|
69
71
|
desc: "Add one-time token (emailed 6-digit code) sign-in (opt-in)"
|
|
70
72
|
|
|
73
|
+
# Also opt-in: WebAuthn passkey sign-in alongside password sign-in.
|
|
74
|
+
# Requires the host app to add the `webauthn` gem itself.
|
|
75
|
+
class_option :passkey, type: :boolean, default: false,
|
|
76
|
+
desc: "Add WebAuthn passkey sign-in (opt-in; requires the webauthn gem)"
|
|
77
|
+
|
|
71
78
|
def install_base_authentication
|
|
72
79
|
say "Running Rails' built-in authentication generator", :green
|
|
73
80
|
Rails::Generators.invoke("rails:authentication", [], behavior: behavior, destination_root: destination_root)
|
|
@@ -117,6 +124,10 @@ module RailsAuthentication
|
|
|
117
124
|
generate_ott if ott?
|
|
118
125
|
end
|
|
119
126
|
|
|
127
|
+
def install_passkey
|
|
128
|
+
generate_passkey if passkey?
|
|
129
|
+
end
|
|
130
|
+
|
|
120
131
|
# Runs after every feature install so the blank line separates the concern
|
|
121
132
|
# includes (if any) from the rest of the class body, no matter which features
|
|
122
133
|
# are enabled.
|
|
@@ -151,6 +162,10 @@ module RailsAuthentication
|
|
|
151
162
|
options[:ott]
|
|
152
163
|
end
|
|
153
164
|
|
|
165
|
+
def passkey?
|
|
166
|
+
options[:passkey]
|
|
167
|
+
end
|
|
168
|
+
|
|
154
169
|
def include_concern_in_user(concern)
|
|
155
170
|
inject_into_class "app/models/user.rb", "User", " include #{concern}\n"
|
|
156
171
|
end
|
|
@@ -5,20 +5,21 @@ module RailsAuthentication
|
|
|
5
5
|
module Features
|
|
6
6
|
module Confirmable
|
|
7
7
|
private
|
|
8
|
-
def generate_confirmable
|
|
9
|
-
template "app/models/concerns/confirmable_concern.rb"
|
|
10
|
-
include_concern_in_user "ConfirmableConcern"
|
|
11
|
-
migration_template "db/migrate/add_confirmable_to_users.rb", "db/migrate/add_confirmable_to_users.rb"
|
|
12
|
-
template "app/controllers/confirmations_controller.rb"
|
|
13
|
-
template "app/views/confirmations/new.html.erb"
|
|
14
|
-
route "resources :confirmations, only: %i[ new create show ], param: :token"
|
|
15
8
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
9
|
+
def generate_confirmable
|
|
10
|
+
template "app/models/concerns/confirmable_concern.rb"
|
|
11
|
+
include_concern_in_user "ConfirmableConcern"
|
|
12
|
+
migration_template "db/migrate/add_confirmable_to_users.rb", "db/migrate/add_confirmable_to_users.rb"
|
|
13
|
+
template "app/controllers/confirmations_controller.rb"
|
|
14
|
+
template "app/views/confirmations/new.html.erb"
|
|
15
|
+
route "resources :confirmations, only: %i[ new create show ], param: :token"
|
|
16
|
+
|
|
17
|
+
if defined?(ActionMailer::Railtie)
|
|
18
|
+
template "app/mailers/confirmations_mailer.rb"
|
|
19
|
+
template "app/views/confirmations_mailer/confirmation_instructions.html.erb"
|
|
20
|
+
template "app/views/confirmations_mailer/confirmation_instructions.text.erb"
|
|
21
21
|
end
|
|
22
|
+
end
|
|
22
23
|
end
|
|
23
24
|
end
|
|
24
25
|
end
|
|
@@ -5,21 +5,22 @@ module RailsAuthentication
|
|
|
5
5
|
module Features
|
|
6
6
|
module Invitable
|
|
7
7
|
private
|
|
8
|
-
def generate_invitable
|
|
9
|
-
template "app/models/concerns/invitable_concern.rb"
|
|
10
|
-
include_concern_in_user "InvitableConcern"
|
|
11
|
-
migration_template "db/migrate/add_invitable_to_users.rb", "db/migrate/add_invitable_to_users.rb"
|
|
12
|
-
template "app/controllers/invitations_controller.rb"
|
|
13
|
-
template "app/views/invitations/new.html.erb"
|
|
14
|
-
template "app/views/invitations/edit.html.erb"
|
|
15
|
-
route "resources :invitations, only: %i[ new create edit update ], param: :token"
|
|
16
8
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
9
|
+
def generate_invitable
|
|
10
|
+
template "app/models/concerns/invitable_concern.rb"
|
|
11
|
+
include_concern_in_user "InvitableConcern"
|
|
12
|
+
migration_template "db/migrate/add_invitable_to_users.rb", "db/migrate/add_invitable_to_users.rb"
|
|
13
|
+
template "app/controllers/invitations_controller.rb"
|
|
14
|
+
template "app/views/invitations/new.html.erb"
|
|
15
|
+
template "app/views/invitations/edit.html.erb"
|
|
16
|
+
route "resources :invitations, only: %i[ new create edit update ], param: :token"
|
|
17
|
+
|
|
18
|
+
if defined?(ActionMailer::Railtie)
|
|
19
|
+
template "app/mailers/invitations_mailer.rb"
|
|
20
|
+
template "app/views/invitations_mailer/invitation_instructions.html.erb"
|
|
21
|
+
template "app/views/invitations_mailer/invitation_instructions.text.erb"
|
|
22
22
|
end
|
|
23
|
+
end
|
|
23
24
|
end
|
|
24
25
|
end
|
|
25
26
|
end
|
|
@@ -5,20 +5,21 @@ module RailsAuthentication
|
|
|
5
5
|
module Features
|
|
6
6
|
module Lockable
|
|
7
7
|
private
|
|
8
|
-
def generate_lockable
|
|
9
|
-
template "app/models/concerns/lockable_concern.rb"
|
|
10
|
-
include_concern_in_user "LockableConcern"
|
|
11
|
-
migration_template "db/migrate/add_lockable_to_users.rb", "db/migrate/add_lockable_to_users.rb"
|
|
12
|
-
template "app/controllers/unlocks_controller.rb"
|
|
13
|
-
template "app/views/unlocks/new.html.erb"
|
|
14
|
-
route "resources :unlocks, only: %i[ new create show ], param: :token"
|
|
15
8
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
9
|
+
def generate_lockable
|
|
10
|
+
template "app/models/concerns/lockable_concern.rb"
|
|
11
|
+
include_concern_in_user "LockableConcern"
|
|
12
|
+
migration_template "db/migrate/add_lockable_to_users.rb", "db/migrate/add_lockable_to_users.rb"
|
|
13
|
+
template "app/controllers/unlocks_controller.rb"
|
|
14
|
+
template "app/views/unlocks/new.html.erb"
|
|
15
|
+
route "resources :unlocks, only: %i[ new create show ], param: :token"
|
|
16
|
+
|
|
17
|
+
if defined?(ActionMailer::Railtie)
|
|
18
|
+
template "app/mailers/unlocks_mailer.rb"
|
|
19
|
+
template "app/views/unlocks_mailer/unlock_instructions.html.erb"
|
|
20
|
+
template "app/views/unlocks_mailer/unlock_instructions.text.erb"
|
|
21
21
|
end
|
|
22
|
+
end
|
|
22
23
|
end
|
|
23
24
|
end
|
|
24
25
|
end
|
|
@@ -8,20 +8,21 @@ module RailsAuthentication
|
|
|
8
8
|
# Recoverable rework.
|
|
9
9
|
module MagicLink
|
|
10
10
|
private
|
|
11
|
-
def generate_magic_link
|
|
12
|
-
template "app/models/concerns/magic_link_concern.rb"
|
|
13
|
-
include_concern_in_user "MagicLinkConcern"
|
|
14
|
-
migration_template "db/migrate/add_magic_link_to_users.rb", "db/migrate/add_magic_link_to_users.rb"
|
|
15
|
-
template "app/controllers/magic_links_controller.rb"
|
|
16
|
-
template "app/views/magic_links/new.html.erb"
|
|
17
|
-
route "resources :magic_links, only: %i[ new create show ], param: :token"
|
|
18
11
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
12
|
+
def generate_magic_link
|
|
13
|
+
template "app/models/concerns/magic_link_concern.rb"
|
|
14
|
+
include_concern_in_user "MagicLinkConcern"
|
|
15
|
+
migration_template "db/migrate/add_magic_link_to_users.rb", "db/migrate/add_magic_link_to_users.rb"
|
|
16
|
+
template "app/controllers/magic_links_controller.rb"
|
|
17
|
+
template "app/views/magic_links/new.html.erb"
|
|
18
|
+
route "resources :magic_links, only: %i[ new create show ], param: :token"
|
|
19
|
+
|
|
20
|
+
if defined?(ActionMailer::Railtie)
|
|
21
|
+
template "app/mailers/magic_links_mailer.rb"
|
|
22
|
+
template "app/views/magic_links_mailer/magic_link.html.erb"
|
|
23
|
+
template "app/views/magic_links_mailer/magic_link.text.erb"
|
|
24
24
|
end
|
|
25
|
+
end
|
|
25
26
|
end
|
|
26
27
|
end
|
|
27
28
|
end
|
|
@@ -10,20 +10,21 @@ module RailsAuthentication
|
|
|
10
10
|
# Registerable, SessionsController#create) is left intact.
|
|
11
11
|
module Ott
|
|
12
12
|
private
|
|
13
|
-
def generate_ott
|
|
14
|
-
template "app/models/concerns/ott_concern.rb"
|
|
15
|
-
include_concern_in_user "OttConcern"
|
|
16
|
-
migration_template "db/migrate/add_ott_to_users.rb", "db/migrate/add_ott_to_users.rb"
|
|
17
|
-
template "app/controllers/otts_controller.rb"
|
|
18
|
-
template "app/views/otts/edit.html.erb"
|
|
19
|
-
route "resource :ott, only: %i[ create edit update ]"
|
|
20
13
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
14
|
+
def generate_ott
|
|
15
|
+
template "app/models/concerns/ott_concern.rb"
|
|
16
|
+
include_concern_in_user "OttConcern"
|
|
17
|
+
migration_template "db/migrate/add_ott_to_users.rb", "db/migrate/add_ott_to_users.rb"
|
|
18
|
+
template "app/controllers/otts_controller.rb"
|
|
19
|
+
template "app/views/otts/edit.html.erb"
|
|
20
|
+
route "resource :ott, only: %i[ create edit update ]"
|
|
21
|
+
|
|
22
|
+
if defined?(ActionMailer::Railtie)
|
|
23
|
+
template "app/mailers/otts_mailer.rb"
|
|
24
|
+
template "app/views/otts_mailer/ott.html.erb"
|
|
25
|
+
template "app/views/otts_mailer/ott.text.erb"
|
|
26
26
|
end
|
|
27
|
+
end
|
|
27
28
|
end
|
|
28
29
|
end
|
|
29
30
|
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsAuthentication
|
|
4
|
+
module Generators
|
|
5
|
+
module Features
|
|
6
|
+
# Opt-in (--passkey): WebAuthn-based passkey sign-in, in addition to
|
|
7
|
+
# password sign-in. A user may register any number of passkeys
|
|
8
|
+
# (platform authenticators, security keys); sign-in is usernameless —
|
|
9
|
+
# the browser's discoverable-credential picker stands in for an email
|
|
10
|
+
# field. Ceremony verification is delegated to the `webauthn` gem —
|
|
11
|
+
# unlike everything else this gem generates, that's a real runtime
|
|
12
|
+
# dependency, so it's added straight to the host app's Gemfile.
|
|
13
|
+
module Passkey
|
|
14
|
+
private
|
|
15
|
+
|
|
16
|
+
def generate_passkey
|
|
17
|
+
gem "webauthn", comment: "Verifies WebAuthn passkey registration/authentication ceremonies"
|
|
18
|
+
say "Added the `webauthn` gem to your Gemfile — run `bundle install`.", :yellow
|
|
19
|
+
|
|
20
|
+
template "config/initializers/webauthn.rb"
|
|
21
|
+
template "app/models/concerns/passkey_concern.rb"
|
|
22
|
+
include_concern_in_user "PasskeyConcern"
|
|
23
|
+
template "app/models/webauthn_credential.rb"
|
|
24
|
+
migration_template "db/migrate/add_webauthn_id_to_users.rb", "db/migrate/add_webauthn_id_to_users.rb"
|
|
25
|
+
migration_template "db/migrate/create_webauthn_credentials.rb", "db/migrate/create_webauthn_credentials.rb"
|
|
26
|
+
template "app/controllers/webauthn_credentials_controller.rb"
|
|
27
|
+
template "app/controllers/passkey_sessions_controller.rb"
|
|
28
|
+
template "app/views/webauthn_credentials/index.html.erb"
|
|
29
|
+
template "app/views/webauthn_credentials/new.html.erb"
|
|
30
|
+
route "resources :webauthn_credentials, only: %i[ index new create destroy ]"
|
|
31
|
+
route "resource :passkey_session, only: %i[ create ], controller: \"passkey_sessions\""
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -5,20 +5,21 @@ module RailsAuthentication
|
|
|
5
5
|
module Features
|
|
6
6
|
module Recoverable
|
|
7
7
|
private
|
|
8
|
-
# Replaces the base generator's stateless generates_token_for password reset
|
|
9
|
-
# with DB-backed, revocable tokens.
|
|
10
|
-
def generate_recoverable
|
|
11
|
-
template "app/models/concerns/recoverable_concern.rb"
|
|
12
|
-
include_concern_in_user "RecoverableConcern"
|
|
13
|
-
migration_template "db/migrate/add_recoverable_to_users.rb", "db/migrate/add_recoverable_to_users.rb"
|
|
14
|
-
template "app/controllers/passwords_controller.rb", force: true
|
|
15
8
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
9
|
+
# Replaces the base generator's stateless generates_token_for password reset
|
|
10
|
+
# with DB-backed, revocable tokens.
|
|
11
|
+
def generate_recoverable
|
|
12
|
+
template "app/models/concerns/recoverable_concern.rb"
|
|
13
|
+
include_concern_in_user "RecoverableConcern"
|
|
14
|
+
migration_template "db/migrate/add_recoverable_to_users.rb", "db/migrate/add_recoverable_to_users.rb"
|
|
15
|
+
template "app/controllers/passwords_controller.rb", force: true
|
|
16
|
+
|
|
17
|
+
if defined?(ActionMailer::Railtie)
|
|
18
|
+
template "app/mailers/passwords_mailer.rb", force: true
|
|
19
|
+
template "app/views/passwords_mailer/reset.html.erb", force: true
|
|
20
|
+
template "app/views/passwords_mailer/reset.text.erb", force: true
|
|
21
21
|
end
|
|
22
|
+
end
|
|
22
23
|
end
|
|
23
24
|
end
|
|
24
25
|
end
|
|
@@ -5,12 +5,13 @@ module RailsAuthentication
|
|
|
5
5
|
module Features
|
|
6
6
|
module Registerable
|
|
7
7
|
private
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
8
|
+
|
|
9
|
+
def generate_registerable
|
|
10
|
+
template "app/controllers/registrations_controller.rb"
|
|
11
|
+
template "app/views/registrations/new.html.erb"
|
|
12
|
+
template "app/views/registrations/edit.html.erb"
|
|
13
|
+
route "resource :registration, only: %i[ new create edit update destroy ]"
|
|
14
|
+
end
|
|
14
15
|
end
|
|
15
16
|
end
|
|
16
17
|
end
|
|
@@ -5,11 +5,12 @@ module RailsAuthentication
|
|
|
5
5
|
module Features
|
|
6
6
|
module Rememberable
|
|
7
7
|
private
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
8
|
+
|
|
9
|
+
def generate_rememberable
|
|
10
|
+
template "app/models/concerns/rememberable_concern.rb"
|
|
11
|
+
include_concern_in_user "RememberableConcern"
|
|
12
|
+
migration_template "db/migrate/add_rememberable_to_users.rb", "db/migrate/add_rememberable_to_users.rb"
|
|
13
|
+
end
|
|
13
14
|
end
|
|
14
15
|
end
|
|
15
16
|
end
|
|
@@ -5,12 +5,13 @@ module RailsAuthentication
|
|
|
5
5
|
module Features
|
|
6
6
|
module Timeoutable
|
|
7
7
|
private
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
8
|
+
|
|
9
|
+
# No migration: inactivity is measured against sessions.updated_at, which the
|
|
10
|
+
# base generator's sessions table already has.
|
|
11
|
+
def generate_timeoutable
|
|
12
|
+
template "app/models/concerns/timeoutable_concern.rb"
|
|
13
|
+
include_concern_in_user "TimeoutableConcern"
|
|
14
|
+
end
|
|
14
15
|
end
|
|
15
16
|
end
|
|
16
17
|
end
|
|
@@ -5,12 +5,13 @@ module RailsAuthentication
|
|
|
5
5
|
module Features
|
|
6
6
|
module Trackable
|
|
7
7
|
private
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
8
|
+
|
|
9
|
+
def generate_trackable
|
|
10
|
+
template "app/models/concerns/trackable_concern.rb"
|
|
11
|
+
include_concern_in_user "TrackableConcern"
|
|
12
|
+
template "app/models/user_auth.rb"
|
|
13
|
+
migration_template "db/migrate/create_user_auths.rb", "db/migrate/create_user_auths.rb"
|
|
14
|
+
end
|
|
14
15
|
end
|
|
15
16
|
end
|
|
16
17
|
end
|
|
@@ -5,10 +5,11 @@ module RailsAuthentication
|
|
|
5
5
|
module Features
|
|
6
6
|
module Validatable
|
|
7
7
|
private
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
8
|
+
|
|
9
|
+
def generate_validatable
|
|
10
|
+
template "app/models/concerns/validatable_concern.rb"
|
|
11
|
+
include_concern_in_user "ValidatableConcern"
|
|
12
|
+
end
|
|
12
13
|
end
|
|
13
14
|
end
|
|
14
15
|
end
|
data/lib/generators/authentication/templates/app/controllers/passkey_sessions_controller.rb.tt
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
class PasskeySessionsController < ApplicationController
|
|
2
|
+
allow_unauthenticated_access
|
|
3
|
+
rate_limit to: 10, within: 3.minutes, only: :create, with: -> { render json: { error: "Try again later." }, status: :too_many_requests }
|
|
4
|
+
|
|
5
|
+
def create
|
|
6
|
+
webauthn_credential = WebAuthn::Credential.from_get(params)
|
|
7
|
+
stored_credential = WebauthnCredential.find_by(external_id: webauthn_credential.id)
|
|
8
|
+
|
|
9
|
+
if stored_credential.nil?
|
|
10
|
+
return render json: { error: "Passkey not recognized." }, status: :unprocessable_content
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
begin
|
|
14
|
+
webauthn_credential.verify(
|
|
15
|
+
session[:webauthn_authentication_challenge],
|
|
16
|
+
public_key: stored_credential.public_key,
|
|
17
|
+
sign_count: stored_credential.sign_count
|
|
18
|
+
)
|
|
19
|
+
rescue WebAuthn::Error
|
|
20
|
+
return render json: { error: "Passkey verification failed." }, status: :unprocessable_content
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
stored_credential.update!(sign_count: webauthn_credential.sign_count)
|
|
24
|
+
user = stored_credential.user
|
|
25
|
+
session.delete(:webauthn_authentication_challenge)
|
|
26
|
+
|
|
27
|
+
<% if lockable? -%>
|
|
28
|
+
if user.locked?
|
|
29
|
+
<% if trackable? -%>
|
|
30
|
+
record_authentication_attempt(user, success: false, failure_reason: "locked")
|
|
31
|
+
<% end -%>
|
|
32
|
+
return render json: { error: "Your account is locked. Check your email for unlock instructions." }, status: :unprocessable_content
|
|
33
|
+
end
|
|
34
|
+
<% end -%>
|
|
35
|
+
<% if confirmable? -%>
|
|
36
|
+
unless user.confirmed?
|
|
37
|
+
<% if trackable? -%>
|
|
38
|
+
record_authentication_attempt(user, success: false, failure_reason: "unconfirmed")
|
|
39
|
+
<% end -%>
|
|
40
|
+
return render json: { error: "You must confirm your email address before signing in." }, status: :unprocessable_content
|
|
41
|
+
end
|
|
42
|
+
<% end -%>
|
|
43
|
+
<% if invitable? -%>
|
|
44
|
+
if user.invitation_pending?
|
|
45
|
+
<% if trackable? -%>
|
|
46
|
+
record_authentication_attempt(user, success: false, failure_reason: "invitation_pending")
|
|
47
|
+
<% end -%>
|
|
48
|
+
return render json: { error: "You must accept your invitation before signing in." }, status: :unprocessable_content
|
|
49
|
+
end
|
|
50
|
+
<% end -%>
|
|
51
|
+
<% if lockable? -%>
|
|
52
|
+
user.reset_failed_attempts!
|
|
53
|
+
<% end -%>
|
|
54
|
+
<% if trackable? -%>
|
|
55
|
+
record_authentication_attempt(user, success: true)
|
|
56
|
+
<% end -%>
|
|
57
|
+
start_new_session_for user
|
|
58
|
+
render json: { redirect_to: after_authentication_url }
|
|
59
|
+
end
|
|
60
|
+
<% if trackable? -%>
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
def record_authentication_attempt(user, success:, failure_reason: nil)
|
|
65
|
+
UserAuth.record(user, request, success: success, failure_reason: failure_reason)
|
|
66
|
+
end
|
|
67
|
+
<% end -%>
|
|
68
|
+
end
|
|
@@ -3,6 +3,10 @@ class SessionsController < ApplicationController
|
|
|
3
3
|
rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_session_path, alert: "Try again later." }
|
|
4
4
|
|
|
5
5
|
def new
|
|
6
|
+
<% if passkey? -%>
|
|
7
|
+
@webauthn_credential_options = WebAuthn::Credential.options_for_get(user_verification: "required")
|
|
8
|
+
session[:webauthn_authentication_challenge] = @webauthn_credential_options.challenge
|
|
9
|
+
<% end -%>
|
|
6
10
|
end
|
|
7
11
|
|
|
8
12
|
def create
|
data/lib/generators/authentication/templates/app/controllers/webauthn_credentials_controller.rb.tt
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
class WebauthnCredentialsController < ApplicationController
|
|
2
|
+
before_action :set_webauthn_credential, only: :destroy
|
|
3
|
+
|
|
4
|
+
def index
|
|
5
|
+
@webauthn_credentials = current_user.webauthn_credentials.order(:created_at)
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
def new
|
|
9
|
+
@webauthn_credential_options = WebAuthn::Credential.options_for_create(
|
|
10
|
+
user: { id: current_user.webauthn_id, name: current_user.email_address },
|
|
11
|
+
exclude: current_user.webauthn_credentials.pluck(:external_id)
|
|
12
|
+
)
|
|
13
|
+
session[:webauthn_registration_challenge] = @webauthn_credential_options.challenge
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def create
|
|
17
|
+
webauthn_credential = WebAuthn::Credential.from_create(params)
|
|
18
|
+
webauthn_credential.verify(session[:webauthn_registration_challenge])
|
|
19
|
+
|
|
20
|
+
current_user.webauthn_credentials.create!(
|
|
21
|
+
external_id: webauthn_credential.id,
|
|
22
|
+
public_key: webauthn_credential.public_key,
|
|
23
|
+
sign_count: webauthn_credential.sign_count,
|
|
24
|
+
nickname: params[:nickname].presence || "Passkey"
|
|
25
|
+
)
|
|
26
|
+
session.delete(:webauthn_registration_challenge)
|
|
27
|
+
|
|
28
|
+
render json: { redirect_to: webauthn_credentials_path }
|
|
29
|
+
rescue WebAuthn::Error => e
|
|
30
|
+
render json: { error: e.message }, status: :unprocessable_content
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def destroy
|
|
34
|
+
@webauthn_credential.destroy
|
|
35
|
+
redirect_to webauthn_credentials_path, notice: "Passkey removed."
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def set_webauthn_credential
|
|
41
|
+
@webauthn_credential = current_user.webauthn_credentials.find(params[:id])
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -47,28 +47,29 @@ module ConfirmableConcern
|
|
|
47
47
|
def generate_confirmation_token!
|
|
48
48
|
update!(confirmation_token: generate_confirmable_token, confirmation_sent_at: Time.current)
|
|
49
49
|
end
|
|
50
|
+
|
|
51
|
+
protected
|
|
52
|
+
|
|
53
|
+
def generate_confirmable_token
|
|
54
|
+
SecureRandom.urlsafe_base64(32)
|
|
55
|
+
end
|
|
50
56
|
<% if reconfirmable? -%>
|
|
51
57
|
|
|
52
58
|
private
|
|
53
|
-
def postponing_email_address_change?
|
|
54
|
-
!@confirming && email_address_changed? && confirmed?
|
|
55
|
-
end
|
|
56
|
-
|
|
57
|
-
def postpone_email_address_change
|
|
58
|
-
self.unconfirmed_email = email_address
|
|
59
|
-
self.email_address = email_address_in_database
|
|
60
|
-
self.confirmation_token = generate_confirmable_token
|
|
61
|
-
self.confirmation_sent_at = Time.current
|
|
62
|
-
end
|
|
63
59
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
<% end -%>
|
|
60
|
+
def postponing_email_address_change?
|
|
61
|
+
!@confirming && email_address_changed? && confirmed?
|
|
62
|
+
end
|
|
68
63
|
|
|
69
|
-
|
|
64
|
+
def postpone_email_address_change
|
|
65
|
+
self.unconfirmed_email = email_address
|
|
66
|
+
self.email_address = email_address_in_database
|
|
67
|
+
self.confirmation_token = generate_confirmable_token
|
|
68
|
+
self.confirmation_sent_at = Time.current
|
|
69
|
+
end
|
|
70
70
|
|
|
71
|
-
def
|
|
72
|
-
|
|
71
|
+
def deliver_reconfirmation_instructions
|
|
72
|
+
ConfirmationsMailer.confirmation_instructions(self).deliver_later
|
|
73
73
|
end
|
|
74
|
+
<% end -%>
|
|
74
75
|
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
module PasskeyConcern
|
|
2
|
+
extend ActiveSupport::Concern
|
|
3
|
+
|
|
4
|
+
included do
|
|
5
|
+
has_many :webauthn_credentials, dependent: :destroy
|
|
6
|
+
|
|
7
|
+
before_validation :ensure_webauthn_id, on: :create
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def passkeys?
|
|
11
|
+
webauthn_credentials.exists?
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
private
|
|
15
|
+
|
|
16
|
+
def ensure_webauthn_id
|
|
17
|
+
self.webauthn_id ||= WebAuthn.generate_user_id
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -30,6 +30,71 @@
|
|
|
30
30
|
<% if magic_link? -%>
|
|
31
31
|
<br><%%= link_to "Sign in with magic link", new_magic_link_path %>
|
|
32
32
|
<% end -%>
|
|
33
|
+
<% if passkey? -%>
|
|
34
|
+
<br><%%= button_tag "Sign in with a passkey", type: "button", id: "passkey-sign-in-button" %>
|
|
35
|
+
<%%= tag.span "", id: "passkey-sign-in-error", style: "color:red" %>
|
|
36
|
+
|
|
37
|
+
<%%= javascript_tag do %>
|
|
38
|
+
(function () {
|
|
39
|
+
function bufferEncode(value) {
|
|
40
|
+
return btoa(String.fromCharCode.apply(null, new Uint8Array(value)))
|
|
41
|
+
.replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function bufferDecode(value) {
|
|
45
|
+
const padded = value.replace(/-/g, "+").replace(/_/g, "/");
|
|
46
|
+
const raw = window.atob(padded);
|
|
47
|
+
const buffer = new Uint8Array(raw.length);
|
|
48
|
+
for (let i = 0; i < raw.length; i++) buffer[i] = raw.charCodeAt(i);
|
|
49
|
+
return buffer.buffer;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
document.getElementById("passkey-sign-in-button").addEventListener("click", async function () {
|
|
53
|
+
const options = <%%= raw @webauthn_credential_options.to_json %>;
|
|
54
|
+
options.challenge = bufferDecode(options.challenge);
|
|
55
|
+
if (options.allowCredentials) {
|
|
56
|
+
options.allowCredentials = options.allowCredentials.map(function (cred) {
|
|
57
|
+
return Object.assign({}, cred, { id: bufferDecode(cred.id) });
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
let credential;
|
|
62
|
+
try {
|
|
63
|
+
credential = await navigator.credentials.get({ publicKey: options });
|
|
64
|
+
} catch (error) {
|
|
65
|
+
document.getElementById("passkey-sign-in-error").textContent = error.message;
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const response = await fetch("<%%= passkey_session_path %>", {
|
|
70
|
+
method: "POST",
|
|
71
|
+
headers: {
|
|
72
|
+
"Content-Type": "application/json",
|
|
73
|
+
"X-CSRF-Token": document.querySelector('meta[name="csrf-token"]').content
|
|
74
|
+
},
|
|
75
|
+
body: JSON.stringify({
|
|
76
|
+
id: credential.id,
|
|
77
|
+
rawId: bufferEncode(credential.rawId),
|
|
78
|
+
type: credential.type,
|
|
79
|
+
response: {
|
|
80
|
+
clientDataJSON: bufferEncode(credential.response.clientDataJSON),
|
|
81
|
+
authenticatorData: bufferEncode(credential.response.authenticatorData),
|
|
82
|
+
signature: bufferEncode(credential.response.signature),
|
|
83
|
+
userHandle: credential.response.userHandle ? bufferEncode(credential.response.userHandle) : null
|
|
84
|
+
}
|
|
85
|
+
})
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
const result = await response.json();
|
|
89
|
+
if (response.ok) {
|
|
90
|
+
window.location.href = result.redirect_to;
|
|
91
|
+
} else {
|
|
92
|
+
document.getElementById("passkey-sign-in-error").textContent = result.error || "Could not sign in with passkey.";
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
})();
|
|
96
|
+
<%% end %>
|
|
97
|
+
<% end -%>
|
|
33
98
|
<br><%%= link_to "Forgot password?", new_password_path %>
|
|
34
99
|
<% if registerable? -%>
|
|
35
100
|
<br><%%= link_to "Sign up", new_registration_path %>
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
<h1>Your passkeys</h1>
|
|
2
|
+
|
|
3
|
+
<%%= tag.div(flash[:alert], style: "color:red") if flash[:alert] %>
|
|
4
|
+
<%%= tag.div(flash[:notice], style: "color:green") if flash[:notice] %>
|
|
5
|
+
|
|
6
|
+
<%% if @webauthn_credentials.empty? %>
|
|
7
|
+
<p>You haven't added a passkey yet.</p>
|
|
8
|
+
<%% else %>
|
|
9
|
+
<ul>
|
|
10
|
+
<%% @webauthn_credentials.each do |credential| %>
|
|
11
|
+
<li>
|
|
12
|
+
<%%= credential.nickname %> — added <%%= credential.created_at.to_date %>
|
|
13
|
+
<%%= button_to "Remove", webauthn_credential_path(credential), method: :delete, data: { turbo_confirm: "Remove this passkey?" } %>
|
|
14
|
+
</li>
|
|
15
|
+
<%% end %>
|
|
16
|
+
</ul>
|
|
17
|
+
<%% end %>
|
|
18
|
+
|
|
19
|
+
<br><%%= link_to "Add a passkey", new_webauthn_credential_path %>
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
<h1>Add a passkey</h1>
|
|
2
|
+
|
|
3
|
+
<%%= tag.div(flash[:alert], style: "color:red") if flash[:alert] %>
|
|
4
|
+
<%%= tag.span "", id: "passkey-error", style: "color:red" %>
|
|
5
|
+
|
|
6
|
+
<%%= label_tag :passkey_nickname, "Name this passkey (e.g. \"MacBook\" or \"iPhone\")" %>
|
|
7
|
+
<%%= text_field_tag :passkey_nickname, nil, placeholder: "Passkey" %><br><br>
|
|
8
|
+
|
|
9
|
+
<%%= button_tag "Create passkey", type: "button", id: "create-passkey-button" %>
|
|
10
|
+
|
|
11
|
+
<%%= javascript_tag do %>
|
|
12
|
+
(function () {
|
|
13
|
+
function bufferEncode(value) {
|
|
14
|
+
return btoa(String.fromCharCode.apply(null, new Uint8Array(value)))
|
|
15
|
+
.replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function bufferDecode(value) {
|
|
19
|
+
const padded = value.replace(/-/g, "+").replace(/_/g, "/");
|
|
20
|
+
const raw = window.atob(padded);
|
|
21
|
+
const buffer = new Uint8Array(raw.length);
|
|
22
|
+
for (let i = 0; i < raw.length; i++) buffer[i] = raw.charCodeAt(i);
|
|
23
|
+
return buffer.buffer;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
document.getElementById("create-passkey-button").addEventListener("click", async function () {
|
|
27
|
+
const options = <%%= raw @webauthn_credential_options.to_json %>;
|
|
28
|
+
|
|
29
|
+
options.challenge = bufferDecode(options.challenge);
|
|
30
|
+
options.user.id = bufferDecode(options.user.id);
|
|
31
|
+
if (options.excludeCredentials) {
|
|
32
|
+
options.excludeCredentials = options.excludeCredentials.map(function (cred) {
|
|
33
|
+
return Object.assign({}, cred, { id: bufferDecode(cred.id) });
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
let credential;
|
|
38
|
+
try {
|
|
39
|
+
credential = await navigator.credentials.create({ publicKey: options });
|
|
40
|
+
} catch (error) {
|
|
41
|
+
document.getElementById("passkey-error").textContent = error.message;
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const response = await fetch("<%%= webauthn_credentials_path %>", {
|
|
46
|
+
method: "POST",
|
|
47
|
+
headers: {
|
|
48
|
+
"Content-Type": "application/json",
|
|
49
|
+
"X-CSRF-Token": document.querySelector('meta[name="csrf-token"]').content
|
|
50
|
+
},
|
|
51
|
+
body: JSON.stringify({
|
|
52
|
+
id: credential.id,
|
|
53
|
+
rawId: bufferEncode(credential.rawId),
|
|
54
|
+
type: credential.type,
|
|
55
|
+
nickname: document.getElementById("passkey_nickname").value,
|
|
56
|
+
response: {
|
|
57
|
+
clientDataJSON: bufferEncode(credential.response.clientDataJSON),
|
|
58
|
+
attestationObject: bufferEncode(credential.response.attestationObject)
|
|
59
|
+
}
|
|
60
|
+
})
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
const result = await response.json();
|
|
64
|
+
if (response.ok) {
|
|
65
|
+
window.location.href = result.redirect_to;
|
|
66
|
+
} else {
|
|
67
|
+
document.getElementById("passkey-error").textContent = result.error || "Could not save passkey.";
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
})();
|
|
71
|
+
<%% end %>
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
WebAuthn.configure do |config|
|
|
2
|
+
# This value needs to match `window.location.hostname` where the API is called from in
|
|
3
|
+
# your client-side (front-end) code.
|
|
4
|
+
# config.rp_id = "example.com"
|
|
5
|
+
|
|
6
|
+
# This value needs to match `window.location.origin` where the API is called from in
|
|
7
|
+
# your client-side (front-end) code.
|
|
8
|
+
# config.allowed_origins = ["https://example.com"]
|
|
9
|
+
|
|
10
|
+
config.rp_name = Rails.application.class.module_parent_name
|
|
11
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
class CreateWebauthnCredentials < ActiveRecord::Migration<%= migration_version %>
|
|
2
|
+
def change
|
|
3
|
+
create_table :webauthn_credentials do |t|
|
|
4
|
+
t.references :user, null: false, foreign_key: true
|
|
5
|
+
t.string :external_id, null: false
|
|
6
|
+
t.string :public_key, null: false
|
|
7
|
+
t.string :nickname
|
|
8
|
+
t.bigint :sign_count, null: false, default: 0
|
|
9
|
+
|
|
10
|
+
t.timestamps
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
add_index :webauthn_credentials, :external_id, unique: true
|
|
14
|
+
end
|
|
15
|
+
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: rails_authentication
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.5.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- David Freerksen
|
|
@@ -39,6 +39,7 @@ files:
|
|
|
39
39
|
- lib/generators/authentication/features/lockable.rb
|
|
40
40
|
- lib/generators/authentication/features/magic_link.rb
|
|
41
41
|
- lib/generators/authentication/features/ott.rb
|
|
42
|
+
- lib/generators/authentication/features/passkey.rb
|
|
42
43
|
- lib/generators/authentication/features/recoverable.rb
|
|
43
44
|
- lib/generators/authentication/features/registerable.rb
|
|
44
45
|
- lib/generators/authentication/features/rememberable.rb
|
|
@@ -50,10 +51,12 @@ files:
|
|
|
50
51
|
- lib/generators/authentication/templates/app/controllers/invitations_controller.rb.tt
|
|
51
52
|
- lib/generators/authentication/templates/app/controllers/magic_links_controller.rb.tt
|
|
52
53
|
- lib/generators/authentication/templates/app/controllers/otts_controller.rb.tt
|
|
54
|
+
- lib/generators/authentication/templates/app/controllers/passkey_sessions_controller.rb.tt
|
|
53
55
|
- lib/generators/authentication/templates/app/controllers/passwords_controller.rb.tt
|
|
54
56
|
- lib/generators/authentication/templates/app/controllers/registrations_controller.rb.tt
|
|
55
57
|
- lib/generators/authentication/templates/app/controllers/sessions_controller.rb.tt
|
|
56
58
|
- lib/generators/authentication/templates/app/controllers/unlocks_controller.rb.tt
|
|
59
|
+
- lib/generators/authentication/templates/app/controllers/webauthn_credentials_controller.rb.tt
|
|
57
60
|
- lib/generators/authentication/templates/app/mailers/confirmations_mailer.rb.tt
|
|
58
61
|
- lib/generators/authentication/templates/app/mailers/invitations_mailer.rb.tt
|
|
59
62
|
- lib/generators/authentication/templates/app/mailers/magic_links_mailer.rb.tt
|
|
@@ -65,12 +68,14 @@ files:
|
|
|
65
68
|
- lib/generators/authentication/templates/app/models/concerns/lockable_concern.rb.tt
|
|
66
69
|
- lib/generators/authentication/templates/app/models/concerns/magic_link_concern.rb.tt
|
|
67
70
|
- lib/generators/authentication/templates/app/models/concerns/ott_concern.rb.tt
|
|
71
|
+
- lib/generators/authentication/templates/app/models/concerns/passkey_concern.rb.tt
|
|
68
72
|
- lib/generators/authentication/templates/app/models/concerns/recoverable_concern.rb.tt
|
|
69
73
|
- lib/generators/authentication/templates/app/models/concerns/rememberable_concern.rb.tt
|
|
70
74
|
- lib/generators/authentication/templates/app/models/concerns/timeoutable_concern.rb.tt
|
|
71
75
|
- lib/generators/authentication/templates/app/models/concerns/trackable_concern.rb.tt
|
|
72
76
|
- lib/generators/authentication/templates/app/models/concerns/validatable_concern.rb.tt
|
|
73
77
|
- lib/generators/authentication/templates/app/models/user_auth.rb.tt
|
|
78
|
+
- lib/generators/authentication/templates/app/models/webauthn_credential.rb.tt
|
|
74
79
|
- lib/generators/authentication/templates/app/views/confirmations/new.html.erb.tt
|
|
75
80
|
- lib/generators/authentication/templates/app/views/confirmations_mailer/confirmation_instructions.html.erb.tt
|
|
76
81
|
- lib/generators/authentication/templates/app/views/confirmations_mailer/confirmation_instructions.text.erb.tt
|
|
@@ -92,6 +97,9 @@ files:
|
|
|
92
97
|
- lib/generators/authentication/templates/app/views/unlocks/new.html.erb.tt
|
|
93
98
|
- lib/generators/authentication/templates/app/views/unlocks_mailer/unlock_instructions.html.erb.tt
|
|
94
99
|
- lib/generators/authentication/templates/app/views/unlocks_mailer/unlock_instructions.text.erb.tt
|
|
100
|
+
- lib/generators/authentication/templates/app/views/webauthn_credentials/index.html.erb.tt
|
|
101
|
+
- lib/generators/authentication/templates/app/views/webauthn_credentials/new.html.erb.tt
|
|
102
|
+
- lib/generators/authentication/templates/config/initializers/webauthn.rb.tt
|
|
95
103
|
- lib/generators/authentication/templates/db/migrate/add_confirmable_to_users.rb.tt
|
|
96
104
|
- lib/generators/authentication/templates/db/migrate/add_invitable_to_users.rb.tt
|
|
97
105
|
- lib/generators/authentication/templates/db/migrate/add_lockable_to_users.rb.tt
|
|
@@ -99,7 +107,9 @@ files:
|
|
|
99
107
|
- lib/generators/authentication/templates/db/migrate/add_ott_to_users.rb.tt
|
|
100
108
|
- lib/generators/authentication/templates/db/migrate/add_recoverable_to_users.rb.tt
|
|
101
109
|
- lib/generators/authentication/templates/db/migrate/add_rememberable_to_users.rb.tt
|
|
110
|
+
- lib/generators/authentication/templates/db/migrate/add_webauthn_id_to_users.rb.tt
|
|
102
111
|
- lib/generators/authentication/templates/db/migrate/create_user_auths.rb.tt
|
|
112
|
+
- lib/generators/authentication/templates/db/migrate/create_webauthn_credentials.rb.tt
|
|
103
113
|
- lib/rails_authentication.rb
|
|
104
114
|
- lib/rails_authentication/version.rb
|
|
105
115
|
homepage: https://github.com/dfreerksen/rails_authentication
|