devise-webauthn 0.0.0 → 0.1.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/.github/workflows/ruby.yml +75 -0
- data/.gitignore +14 -0
- data/.rspec +0 -1
- data/.rubocop.yml +38 -0
- data/.ruby-version +1 -0
- data/Appraisals +25 -0
- data/CHANGELOG.md +13 -0
- data/Gemfile +19 -1
- data/Gemfile.lock +363 -0
- data/LICENSE.txt +1 -1
- data/README.md +121 -7
- data/Rakefile +3 -1
- data/app/controllers/devise/passkeys_controller.rb +57 -0
- data/app/views/devise/passkeys/new.html.erb +5 -0
- data/app/views/devise/sessions/new.html.erb +28 -0
- data/bin/console +1 -0
- data/bin/rails +15 -0
- data/config/locales/en.yml +8 -0
- data/config.ru +10 -0
- data/devise-webauthn.gemspec +15 -10
- data/gemfiles/rails_7_1.gemfile +23 -0
- data/gemfiles/rails_7_2.gemfile +21 -0
- data/gemfiles/rails_8_0.gemfile +21 -0
- data/gemfiles/rails_edge.gemfile +21 -0
- data/lib/devise/models/passkey_authenticatable.rb +22 -0
- data/lib/devise/strategies/passkey_authenticatable.rb +45 -0
- data/lib/devise/webauthn/engine.rb +30 -0
- data/lib/devise/webauthn/helpers/credentials_helper.rb +77 -0
- data/lib/devise/webauthn/routes.rb +13 -0
- data/lib/devise/webauthn/test/authenticator_helpers.rb +17 -0
- data/lib/devise/webauthn/url_helpers.rb +44 -0
- data/lib/devise/webauthn/version.rb +3 -1
- data/lib/devise/webauthn.rb +13 -3
- data/lib/generators/devise/webauthn/controllers_generator.rb +29 -0
- data/lib/generators/devise/webauthn/install/install_generator.rb +39 -0
- data/lib/generators/devise/webauthn/install/templates/webauthn.rb +37 -0
- data/lib/generators/devise/webauthn/stimulus/stimulus_generator.rb +24 -0
- data/lib/generators/devise/webauthn/stimulus/templates/webauthn_credentials_controller.js +31 -0
- data/lib/generators/devise/webauthn/templates/controllers/README +14 -0
- data/lib/generators/devise/webauthn/templates/controllers/passkeys_controller.rb.tt +31 -0
- data/lib/generators/devise/webauthn/views_generator.rb +44 -0
- data/lib/generators/devise/webauthn/webauthn_credential_model/webauthn_credential_model_generator.rb +54 -0
- data/lib/generators/devise/webauthn/webauthn_id/webauthn_id_generator.rb +41 -0
- metadata +51 -34
- data/.travis.yml +0 -7
data/README.md
CHANGED
|
@@ -1,8 +1,14 @@
|
|
|
1
1
|
# Devise::Webauthn
|
|
2
|
+
[](https://badge.fury.io/rb/devise-webauthn)
|
|
2
3
|
|
|
3
|
-
|
|
4
|
+
Devise::Webauthn is a [Devise](https://github.com/heartcombo/devise) extension that adds [WebAuthn](https://www.w3.org/TR/2025/WD-webauthn-3-20250127/) support to your Rails application, allowing users to authenticate with [passkeys](https://www.w3.org/TR/2025/WD-webauthn-3-20250127/#passkey).
|
|
4
5
|
|
|
5
|
-
|
|
6
|
+
## Requirements
|
|
7
|
+
|
|
8
|
+
- **Ruby**: 2.7+
|
|
9
|
+
- **Stimulus Rails**: This gem requires [stimulus-rails](https://github.com/hotwired/stimulus-rails) to be installed and configured in your application.
|
|
10
|
+
> **Note:** Stimulus Rails is needed for the generated code to work out of the box.
|
|
11
|
+
> If you prefer not to have this dependency, you’ll need to manually implement the JavaScript logic for WebAuthn interactions.
|
|
6
12
|
|
|
7
13
|
## Installation
|
|
8
14
|
|
|
@@ -14,7 +20,7 @@ gem 'devise-webauthn'
|
|
|
14
20
|
|
|
15
21
|
And then execute:
|
|
16
22
|
|
|
17
|
-
$ bundle
|
|
23
|
+
$ bundle install
|
|
18
24
|
|
|
19
25
|
Or install it yourself as:
|
|
20
26
|
|
|
@@ -22,17 +28,125 @@ Or install it yourself as:
|
|
|
22
28
|
|
|
23
29
|
## Usage
|
|
24
30
|
|
|
25
|
-
|
|
31
|
+
First, ensure you have Devise set up in your Rails application. For a full guide on setting up Devise, refer to the [Devise documentation](https://github.com/heartcombo/devise?tab=readme-ov-file#getting-started).
|
|
32
|
+
Then, follow these steps to integrate Devise::Webauthn:
|
|
33
|
+
1. **Run Devise::Webauthn Generator:**
|
|
34
|
+
Run the generator to set up necessary configurations, migrations, and Stimulus controller:
|
|
35
|
+
```bash
|
|
36
|
+
$ bin/rails generate devise:webauthn:install
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
You can optionally specify a different resource name (defaults to "user"):
|
|
40
|
+
```bash
|
|
41
|
+
$ bin/rails generate devise:webauthn:install --resource-name=RESOURCE_NAME
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
The generator will:
|
|
45
|
+
- Create the WebAuthn initializer (`config/initializers/webauthn.rb`)
|
|
46
|
+
- Generate the `WebauthnCredential` model and migration
|
|
47
|
+
- Add `webauthn_id` field to your devise model (e.g., `User`)
|
|
48
|
+
- Install the Stimulus controller
|
|
49
|
+
|
|
50
|
+
2. **Run Migrations:**
|
|
51
|
+
After running the generator, execute the migrations to update your database schema:
|
|
52
|
+
```bash
|
|
53
|
+
$ bin/rails db:migrate
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
3. **Update Your Devise Model:**
|
|
57
|
+
Add `:passkey_authenticatable` to your Devise model (e.g., `User`):
|
|
58
|
+
```ruby
|
|
59
|
+
class User < ApplicationRecord
|
|
60
|
+
devise :database_authenticatable, :registerable,
|
|
61
|
+
:recoverable, :rememberable, :validatable, :passkey_authenticatable
|
|
62
|
+
end
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
4. **Configure WebAuthn Settings:**
|
|
66
|
+
Update the generated initializer file `config/initializers/webauthn.rb` with your application's specific settings, such as `rp_name`, and `allowed_origins`. For example:
|
|
67
|
+
```ruby
|
|
68
|
+
WebAuthn.configure do |config|
|
|
69
|
+
# This value needs to match `window.location.origin` evaluated by
|
|
70
|
+
# the User Agent during registration and authentication ceremonies.
|
|
71
|
+
config.allowed_origins = ["https://yourapp.com"]
|
|
72
|
+
|
|
73
|
+
# Relying Party name for display purposes
|
|
74
|
+
config.rp_name = "Your App Name"
|
|
75
|
+
end
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## How It Works
|
|
79
|
+
|
|
80
|
+
### Adding Passkeys
|
|
81
|
+
Signed-in users can add passkeys by visiting `/users/passkeys/new`.
|
|
82
|
+
|
|
83
|
+
### Sign In with Passkeys
|
|
84
|
+
When a user visits `/users/sign_in` they can choose to authenticate using a passkey. The authentication flow is handled by `PasskeysAuthenticatable` strategy.
|
|
85
|
+
|
|
86
|
+
The WebAuthn passkey sign-in flow works as follows:
|
|
87
|
+
1. User clicks "Sign in with Passkey", starting a WebAuthn authentication ceremony.
|
|
88
|
+
2. Browser shows available passkeys.
|
|
89
|
+
3. User selects a passkey and verifies with their [authenticator](https://www.w3.org/TR/webauthn-3/#webauthn-authenticator).
|
|
90
|
+
4. The server verifies the response and signs in the user.
|
|
91
|
+
|
|
92
|
+
## Customization
|
|
93
|
+
|
|
94
|
+
### Customizing Views
|
|
95
|
+
Similar to [views customization on Devise](https://github.com/heartcombo/devise?tab=readme-ov-file#configuring-views), to customize the views, you can copy the view files from the gem into your application. Run the following command:
|
|
96
|
+
```bash
|
|
97
|
+
$ bin/rails generate devise:webauthn:views
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
If you want to customize only specific views, you can copy them individually. For example, to copy only the passkeys views:
|
|
101
|
+
```bash
|
|
102
|
+
$ bin/rails generate devise:webauthn:views -v passkeys
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
### Helper methods
|
|
106
|
+
Devise::Webauthn provides helpers that can be used in your views. For example, for a resource named `user`, you can use the following helpers:
|
|
107
|
+
|
|
108
|
+
To add a button for logging in with passkeys:
|
|
109
|
+
```erb
|
|
110
|
+
<%= login_with_passkey_button("Log in with passkeys", session_path: user_session_path) %>
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
To add a passkeys creation form:
|
|
114
|
+
```erb
|
|
115
|
+
<%= passkey_creation_form_for(current_user) do |form| %>
|
|
116
|
+
<%= form.label :name, 'Passkey name' %>
|
|
117
|
+
<%= form.text_field :name, required: true %>
|
|
118
|
+
<%= form.submit 'Create Passkey' %>
|
|
119
|
+
<% end %>
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
### Customizing Controllers
|
|
123
|
+
Similar to [controllers customization on Devise](https://github.com/heartcombo/devise?tab=readme-ov-file#configuring-controllers), you can customize the Devise::Webauthn controllers.
|
|
124
|
+
|
|
125
|
+
1. Create your custom controllers using the generator which requires a scope:
|
|
126
|
+
```bash
|
|
127
|
+
$ bin/rails generate devise:webauthn:controllers [scope]
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
2. Tell the router to use your custom controllers. For example, if your scope is `users`:
|
|
131
|
+
```ruby
|
|
132
|
+
devise_for :users, controllers: {
|
|
133
|
+
passkeys: 'users/passkeys'
|
|
134
|
+
}
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
3. Change or extend the generated controllers as needed.
|
|
138
|
+
|
|
26
139
|
|
|
27
140
|
## Development
|
|
28
141
|
|
|
29
|
-
After checking out the repo, run `bin/setup` to install dependencies. Then, run `
|
|
142
|
+
After checking out the repo, run `bin/setup` to install dependencies. Then, run `bundle exec rspec` to run the tests.
|
|
143
|
+
To run the linter, use `bundle exec rubocop`.
|
|
30
144
|
|
|
31
|
-
|
|
145
|
+
Before submitting a pull request, ensure that tests and linter pass.
|
|
32
146
|
|
|
33
147
|
## Contributing
|
|
34
148
|
|
|
35
|
-
Bug reports and pull requests are welcome on GitHub at https://github.com/
|
|
149
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/cedarcode/devise-webauthn.
|
|
36
150
|
|
|
37
151
|
## License
|
|
38
152
|
|
data/Rakefile
CHANGED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Devise
|
|
4
|
+
class PasskeysController < DeviseController
|
|
5
|
+
before_action :authenticate_scope!
|
|
6
|
+
|
|
7
|
+
def new; end
|
|
8
|
+
|
|
9
|
+
def create
|
|
10
|
+
passkey_from_params = WebAuthn::Credential.from_create(JSON.parse(params[:public_key_credential]))
|
|
11
|
+
|
|
12
|
+
if verify_and_save_passkey(passkey_from_params)
|
|
13
|
+
set_flash_message! :notice, :passkey_created
|
|
14
|
+
else
|
|
15
|
+
set_flash_message! :alert, :passkey_verification_failed, scope: :"devise.failure"
|
|
16
|
+
end
|
|
17
|
+
redirect_to after_update_path
|
|
18
|
+
rescue WebAuthn::Error
|
|
19
|
+
set_flash_message! :alert, :passkey_verification_failed, scope: :"devise.failure"
|
|
20
|
+
redirect_to after_update_path
|
|
21
|
+
ensure
|
|
22
|
+
session.delete(:webauthn_challenge)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def destroy
|
|
26
|
+
resource.passkeys.destroy(params[:id])
|
|
27
|
+
redirect_to after_update_path
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def authenticate_scope!
|
|
33
|
+
send(:"authenticate_#{resource_name}!", force: true)
|
|
34
|
+
self.resource = send(:"current_#{resource_name}")
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def verify_and_save_passkey(passkey_from_params)
|
|
38
|
+
passkey_from_params.verify(
|
|
39
|
+
session[:webauthn_challenge],
|
|
40
|
+
user_verification: true
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
resource.passkeys.create(
|
|
44
|
+
external_id: passkey_from_params.id,
|
|
45
|
+
name: params[:name],
|
|
46
|
+
public_key: passkey_from_params.public_key,
|
|
47
|
+
sign_count: passkey_from_params.sign_count
|
|
48
|
+
)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# The default url to be used after creating a passkey. You can overwrite
|
|
52
|
+
# this method in your own PasskeysController.
|
|
53
|
+
def after_update_path
|
|
54
|
+
new_passkey_path(resource_name)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
<h2>Log in</h2>
|
|
2
|
+
|
|
3
|
+
<%= form_for(resource, as: resource_name, url: session_path(resource_name)) do |f| %>
|
|
4
|
+
<div class="field">
|
|
5
|
+
<%= f.label :email %><br />
|
|
6
|
+
<%= f.email_field :email, autofocus: true, autocomplete: "email" %>
|
|
7
|
+
</div>
|
|
8
|
+
|
|
9
|
+
<div class="field">
|
|
10
|
+
<%= f.label :password %><br />
|
|
11
|
+
<%= f.password_field :password, autocomplete: "current-password" %>
|
|
12
|
+
</div>
|
|
13
|
+
|
|
14
|
+
<% if devise_mapping.rememberable? %>
|
|
15
|
+
<div class="field">
|
|
16
|
+
<%= f.check_box :remember_me %>
|
|
17
|
+
<%= f.label :remember_me %>
|
|
18
|
+
</div>
|
|
19
|
+
<% end %>
|
|
20
|
+
|
|
21
|
+
<div class="actions">
|
|
22
|
+
<%= f.submit "Log in" %>
|
|
23
|
+
</div>
|
|
24
|
+
<% end %>
|
|
25
|
+
|
|
26
|
+
<%= login_with_passkey_button("Log in with passkeys", session_path: session_path(resource_name)) %>
|
|
27
|
+
|
|
28
|
+
<%= render "devise/shared/links" %>
|
data/bin/console
CHANGED
data/bin/rails
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# This command will automatically be run when you run "rails" with Rails gems
|
|
5
|
+
# installed from the root of your application.
|
|
6
|
+
|
|
7
|
+
ENGINE_ROOT = File.expand_path("..", __dir__)
|
|
8
|
+
ENGINE_PATH = File.expand_path("../lib/devise/webauthn/engine", __dir__)
|
|
9
|
+
|
|
10
|
+
# Set up gems listed in the Gemfile.
|
|
11
|
+
ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__)
|
|
12
|
+
require "bundler/setup" if File.exist?(ENV["BUNDLE_GEMFILE"])
|
|
13
|
+
|
|
14
|
+
require "rails/all"
|
|
15
|
+
require "rails/engine/commands"
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
en:
|
|
2
|
+
devise:
|
|
3
|
+
passkeys:
|
|
4
|
+
passkey_created: "Passkey created successfully."
|
|
5
|
+
passkey_creation_failed: "Passkey creation failed."
|
|
6
|
+
failure:
|
|
7
|
+
passkey_not_found: "Your passkey doesn't exist or is not valid."
|
|
8
|
+
passkey_verification_failed: "Passkey verification failed."
|
data/config.ru
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rubygems"
|
|
4
|
+
require "bundler"
|
|
5
|
+
require "combustion"
|
|
6
|
+
|
|
7
|
+
Combustion.initialize! :active_model, :active_record, :action_controller, :action_view do
|
|
8
|
+
config.load_defaults Rails.version.to_f
|
|
9
|
+
end
|
|
10
|
+
run Combustion::Application
|
data/devise-webauthn.gemspec
CHANGED
|
@@ -1,29 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
1
2
|
|
|
2
|
-
lib = File.expand_path("
|
|
3
|
+
lib = File.expand_path("lib", __dir__)
|
|
3
4
|
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
|
4
5
|
require "devise/webauthn/version"
|
|
5
6
|
|
|
6
7
|
Gem::Specification.new do |spec|
|
|
7
8
|
spec.name = "devise-webauthn"
|
|
8
9
|
spec.version = Devise::Webauthn::VERSION
|
|
9
|
-
spec.authors = ["
|
|
10
|
-
spec.email = ["
|
|
10
|
+
spec.authors = ["Cedarcode"]
|
|
11
|
+
spec.email = ["webauthn@cedarcode.com"]
|
|
11
12
|
|
|
12
|
-
spec.summary =
|
|
13
|
+
spec.summary = "Devise extension to support WebAuthn."
|
|
13
14
|
spec.license = "MIT"
|
|
14
15
|
|
|
16
|
+
spec.homepage = "https://github.com/cedarcode/devise-webauthn"
|
|
17
|
+
spec.metadata["homepage_uri"] = spec.homepage
|
|
18
|
+
spec.metadata["source_code_uri"] = spec.homepage
|
|
19
|
+
spec.metadata["changelog_uri"] = "#{spec.homepage}/blob/main/CHANGELOG.md"
|
|
20
|
+
|
|
15
21
|
# Specify which files should be added to the gem when it is released.
|
|
16
22
|
# The `git ls-files -z` loads the files in the RubyGem that have been added into git.
|
|
17
|
-
spec.files
|
|
23
|
+
spec.files = Dir.chdir(File.expand_path(__dir__)) do
|
|
18
24
|
`git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
|
|
19
25
|
end
|
|
20
26
|
spec.bindir = "exe"
|
|
21
27
|
spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
|
|
22
28
|
spec.require_paths = ["lib"]
|
|
29
|
+
spec.metadata["rubygems_mfa_required"] = "true"
|
|
30
|
+
spec.required_ruby_version = ">= 2.7"
|
|
23
31
|
|
|
24
|
-
spec.
|
|
25
|
-
|
|
26
|
-
spec.add_development_dependency "bundler", "~> 1.17"
|
|
27
|
-
spec.add_development_dependency "rake", "~> 10.0"
|
|
28
|
-
spec.add_development_dependency "rspec", "~> 3.0"
|
|
32
|
+
spec.add_dependency "devise", "~> 4.9"
|
|
33
|
+
spec.add_dependency "webauthn", "~> 3.0"
|
|
29
34
|
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# This file was generated by Appraisal
|
|
2
|
+
|
|
3
|
+
source "https://rubygems.org"
|
|
4
|
+
|
|
5
|
+
gem "appraisal", "~> 2.5"
|
|
6
|
+
gem "capybara", "~> 3.39"
|
|
7
|
+
gem "combustion", "~> 1.3"
|
|
8
|
+
gem "importmap-rails", "~> 2.0"
|
|
9
|
+
gem "propshaft", "~> 1.2"
|
|
10
|
+
gem "pry-byebug", "~> 3.10"
|
|
11
|
+
gem "puma", "~> 6.6"
|
|
12
|
+
gem "rails", "~> 7.1"
|
|
13
|
+
gem "rspec-rails", "~> 7.1"
|
|
14
|
+
gem "rubocop", "~> 1.79"
|
|
15
|
+
gem "rubocop-rails", "~> 2.32"
|
|
16
|
+
gem "rubocop-rspec", "~> 3.6"
|
|
17
|
+
gem "selenium-webdriver"
|
|
18
|
+
gem "sqlite3", "~> 1.7"
|
|
19
|
+
gem "stimulus-rails", "~> 1.3"
|
|
20
|
+
gem "psych", "~> 4.0"
|
|
21
|
+
gem "rack", "~> 2.2"
|
|
22
|
+
|
|
23
|
+
gemspec path: "../"
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# This file was generated by Appraisal
|
|
2
|
+
|
|
3
|
+
source "https://rubygems.org"
|
|
4
|
+
|
|
5
|
+
gem "appraisal", "~> 2.5"
|
|
6
|
+
gem "capybara", "~> 3.40"
|
|
7
|
+
gem "combustion", "~> 1.3"
|
|
8
|
+
gem "importmap-rails", "~> 2.2"
|
|
9
|
+
gem "propshaft", "~> 1.2"
|
|
10
|
+
gem "pry-byebug", "~> 3.11"
|
|
11
|
+
gem "puma", "~> 6.6"
|
|
12
|
+
gem "rails", "~> 7.2"
|
|
13
|
+
gem "rspec-rails", "~> 8.0"
|
|
14
|
+
gem "rubocop", "~> 1.79"
|
|
15
|
+
gem "rubocop-rails", "~> 2.32"
|
|
16
|
+
gem "rubocop-rspec", "~> 3.6"
|
|
17
|
+
gem "selenium-webdriver"
|
|
18
|
+
gem "sqlite3", "~> 2.7"
|
|
19
|
+
gem "stimulus-rails", "~> 1.3"
|
|
20
|
+
|
|
21
|
+
gemspec path: "../"
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# This file was generated by Appraisal
|
|
2
|
+
|
|
3
|
+
source "https://rubygems.org"
|
|
4
|
+
|
|
5
|
+
gem "appraisal", "~> 2.5"
|
|
6
|
+
gem "capybara", "~> 3.40"
|
|
7
|
+
gem "combustion", "~> 1.3"
|
|
8
|
+
gem "importmap-rails", "~> 2.2"
|
|
9
|
+
gem "propshaft", "~> 1.2"
|
|
10
|
+
gem "pry-byebug", "~> 3.11"
|
|
11
|
+
gem "puma", "~> 6.6"
|
|
12
|
+
gem "rails", "~> 8.0"
|
|
13
|
+
gem "rspec-rails", "~> 8.0"
|
|
14
|
+
gem "rubocop", "~> 1.79"
|
|
15
|
+
gem "rubocop-rails", "~> 2.32"
|
|
16
|
+
gem "rubocop-rspec", "~> 3.6"
|
|
17
|
+
gem "selenium-webdriver"
|
|
18
|
+
gem "sqlite3", "~> 2.7"
|
|
19
|
+
gem "stimulus-rails", "~> 1.3"
|
|
20
|
+
|
|
21
|
+
gemspec path: "../"
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# This file was generated by Appraisal
|
|
2
|
+
|
|
3
|
+
source "https://rubygems.org"
|
|
4
|
+
|
|
5
|
+
gem "appraisal", "~> 2.5"
|
|
6
|
+
gem "capybara", "~> 3.40"
|
|
7
|
+
gem "combustion", "~> 1.3"
|
|
8
|
+
gem "importmap-rails", "~> 2.2"
|
|
9
|
+
gem "propshaft", "~> 1.2"
|
|
10
|
+
gem "pry-byebug", "~> 3.11"
|
|
11
|
+
gem "puma", "~> 6.6"
|
|
12
|
+
gem "rails", branch: "main", git: "https://github.com/rails/rails"
|
|
13
|
+
gem "rspec-rails", "~> 8.0"
|
|
14
|
+
gem "rubocop", "~> 1.79"
|
|
15
|
+
gem "rubocop-rails", "~> 2.32"
|
|
16
|
+
gem "rubocop-rspec", "~> 3.6"
|
|
17
|
+
gem "selenium-webdriver"
|
|
18
|
+
gem "sqlite3", "~> 2.7"
|
|
19
|
+
gem "stimulus-rails", "~> 1.3"
|
|
20
|
+
|
|
21
|
+
gemspec path: "../"
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support/concern"
|
|
4
|
+
require "devise/strategies/passkey_authenticatable"
|
|
5
|
+
|
|
6
|
+
module Devise
|
|
7
|
+
module Models
|
|
8
|
+
module PasskeyAuthenticatable
|
|
9
|
+
extend ActiveSupport::Concern
|
|
10
|
+
|
|
11
|
+
included do
|
|
12
|
+
has_many :passkeys, dependent: :destroy, class_name: "WebauthnCredential"
|
|
13
|
+
|
|
14
|
+
validates :webauthn_id, uniqueness: true, allow_blank: true
|
|
15
|
+
|
|
16
|
+
after_initialize do
|
|
17
|
+
self.webauthn_id ||= WebAuthn.generate_user_id
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Devise
|
|
4
|
+
module Strategies
|
|
5
|
+
class PasskeyAuthenticatable < Warden::Strategies::Base
|
|
6
|
+
def valid?
|
|
7
|
+
passkey_param.present? && session[:authentication_challenge].present?
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def authenticate!
|
|
11
|
+
passkey_from_params = WebAuthn::Credential.from_get(JSON.parse(passkey_param))
|
|
12
|
+
stored_passkey = WebauthnCredential.find_by(external_id: passkey_from_params.id)
|
|
13
|
+
|
|
14
|
+
return fail!(:passkey_not_found) if stored_passkey.blank?
|
|
15
|
+
|
|
16
|
+
verify_passkeys(passkey_from_params, stored_passkey)
|
|
17
|
+
|
|
18
|
+
success!(stored_passkey.user)
|
|
19
|
+
rescue WebAuthn::Error
|
|
20
|
+
fail!(:passkey_verification_failed)
|
|
21
|
+
ensure
|
|
22
|
+
session.delete(:authentication_challenge)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def passkey_param
|
|
28
|
+
params[:public_key_credential]
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def verify_passkeys(passkey_from_params, stored_passkey)
|
|
32
|
+
passkey_from_params.verify(
|
|
33
|
+
session[:authentication_challenge],
|
|
34
|
+
public_key: stored_passkey.public_key,
|
|
35
|
+
sign_count: stored_passkey.sign_count,
|
|
36
|
+
user_verification: true
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
stored_passkey.update!(sign_count: passkey_from_params.sign_count)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
Warden::Strategies.add(:passkey_authenticatable, Devise::Strategies::PasskeyAuthenticatable)
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Devise
|
|
4
|
+
module Webauthn
|
|
5
|
+
class Engine < ::Rails::Engine
|
|
6
|
+
isolate_namespace Devise::Webauthn
|
|
7
|
+
|
|
8
|
+
initializer "devise.webauthn.add_module" do
|
|
9
|
+
Devise.add_module(
|
|
10
|
+
:passkey_authenticatable,
|
|
11
|
+
{
|
|
12
|
+
model: "devise/models/passkey_authenticatable",
|
|
13
|
+
strategy: true,
|
|
14
|
+
route: { passkey_authentication: [] }
|
|
15
|
+
}
|
|
16
|
+
)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
initializer "devise.webauthn.helpers" do
|
|
20
|
+
ActiveSupport.on_load(:action_view) do
|
|
21
|
+
include Devise::Webauthn::CredentialsHelper
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
initializer "devise.webauthn.url_helpers" do
|
|
26
|
+
Devise.include_helpers(Devise::Webauthn)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Devise
|
|
4
|
+
module Webauthn
|
|
5
|
+
module CredentialsHelper
|
|
6
|
+
def passkey_creation_form_for(resource, form_classes: nil, &block)
|
|
7
|
+
form_with(
|
|
8
|
+
url: passkeys_path(resource),
|
|
9
|
+
method: :post,
|
|
10
|
+
class: form_classes,
|
|
11
|
+
data: {
|
|
12
|
+
action: "webauthn-credentials#create:prevent",
|
|
13
|
+
controller: "webauthn-credentials",
|
|
14
|
+
webauthn_credentials_options_param: create_passkey_options(resource)
|
|
15
|
+
}
|
|
16
|
+
) do |f|
|
|
17
|
+
concat f.hidden_field(:public_key_credential,
|
|
18
|
+
data: { "webauthn-credentials-target": "credentialHiddenInput" })
|
|
19
|
+
concat capture(f, &block)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def login_with_passkey_button(text = nil, session_path:, button_classes: nil, form_classes: nil, &block)
|
|
24
|
+
form_with(
|
|
25
|
+
url: session_path,
|
|
26
|
+
method: :post,
|
|
27
|
+
data: {
|
|
28
|
+
action: "webauthn-credentials#get:prevent",
|
|
29
|
+
controller: "webauthn-credentials",
|
|
30
|
+
webauthn_credentials_options_param: webauthn_authentication_options
|
|
31
|
+
},
|
|
32
|
+
class: form_classes
|
|
33
|
+
) do |f|
|
|
34
|
+
concat f.hidden_field(:public_key_credential,
|
|
35
|
+
data: { "webauthn-credentials-target": "credentialHiddenInput" })
|
|
36
|
+
concat f.button(text, type: "submit", class: button_classes, &block)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def create_passkey_options(resource)
|
|
43
|
+
@create_passkey_options ||= begin
|
|
44
|
+
options = WebAuthn::Credential.options_for_create(
|
|
45
|
+
user: {
|
|
46
|
+
id: resource.webauthn_id,
|
|
47
|
+
name: resource.email
|
|
48
|
+
},
|
|
49
|
+
exclude: resource.passkeys.pluck(:external_id),
|
|
50
|
+
authenticator_selection: {
|
|
51
|
+
resident_key: "required",
|
|
52
|
+
user_verification: "required"
|
|
53
|
+
}
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
# Store challenge in session for later verification
|
|
57
|
+
session[:webauthn_challenge] = options.challenge
|
|
58
|
+
|
|
59
|
+
options
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def webauthn_authentication_options
|
|
64
|
+
@webauthn_authentication_options ||= begin
|
|
65
|
+
options = WebAuthn::Credential.options_for_get(
|
|
66
|
+
user_verification: "required"
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
# Store challenge in session for later verification
|
|
70
|
+
session[:authentication_challenge] = options.challenge
|
|
71
|
+
|
|
72
|
+
options
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActionDispatch
|
|
4
|
+
module Routing
|
|
5
|
+
class Mapper
|
|
6
|
+
protected
|
|
7
|
+
|
|
8
|
+
def devise_passkey_authentication(_mapping, controllers)
|
|
9
|
+
resources :passkeys, only: %i[new create destroy], controller: controllers[:passkeys]
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Devise
|
|
4
|
+
module Webauthn
|
|
5
|
+
module Test
|
|
6
|
+
module AuthenticatorHelpers
|
|
7
|
+
def add_virtual_authenticator
|
|
8
|
+
options = Selenium::WebDriver::VirtualAuthenticatorOptions.new
|
|
9
|
+
options.user_verification = true
|
|
10
|
+
options.user_verified = true
|
|
11
|
+
options.resident_key = true
|
|
12
|
+
page.driver.browser.add_virtual_authenticator(options)
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|