devise-webauthn 0.1.2 → 0.2.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/FUNDING.yml +1 -0
- data/.github/workflows/release.yml +28 -0
- data/.github/workflows/ruby.yml +7 -0
- data/.rubocop.yml +1 -0
- data/Appraisals +4 -0
- data/CHANGELOG.md +13 -0
- data/Gemfile.lock +1 -1
- data/README.md +24 -5
- data/app/controllers/devise/second_factor_webauthn_credentials_controller.rb +56 -0
- data/app/controllers/devise/two_factor_authentications_controller.rb +38 -0
- data/app/views/devise/second_factor_webauthn_credentials/new.html.erb +5 -0
- data/app/views/devise/two_factor_authentications/new.html.erb +1 -0
- data/config/locales/en.yml +6 -1
- data/gemfiles/rails_8_1.gemfile +21 -0
- data/lib/devise/models/passkey_authenticatable.rb +3 -7
- data/lib/devise/models/webauthn_credential_authenticatable.rb +21 -0
- data/lib/devise/models/webauthn_two_factor_authenticatable.rb +22 -0
- data/lib/devise/strategies/database_authenticatable.rb +52 -0
- data/lib/devise/strategies/passkey_authenticatable.rb +1 -1
- data/lib/devise/strategies/webauthn_two_factor_authenticatable.rb +51 -0
- data/lib/devise/webauthn/engine.rb +9 -0
- data/lib/devise/webauthn/helpers/credentials_helper.rb +82 -4
- data/lib/devise/webauthn/routes.rb +10 -0
- data/lib/devise/webauthn/url_helpers.rb +4 -1
- data/lib/devise/webauthn/version.rb +1 -1
- data/lib/generators/devise/webauthn/webauthn_credential_model/webauthn_credential_model_generator.rb +6 -1
- metadata +14 -7
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8083a823cce7edf858475a062ea009193c3a86cf9b4a821db1dd2715430580d4
|
|
4
|
+
data.tar.gz: df00de569113a642c13f87cc4f6c93836acb5c5df1ef1cecfe492b01247a17aa
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ce764390d121fc68bd1732d655700ccae382ced384a24f350256f2bb633910d0038972637a8e9f4f5e512d0e13030c53851f7288733fb233e8d86fe00ab5c8b8
|
|
7
|
+
data.tar.gz: d2072d245253437be40005c81b9879f22f0a7c0fa3e0e3cd39b9b03f812aa05cdcd7404cbd4123afac473785364472f3e025dbdf025ae3f74af117854dcbca38
|
data/.github/FUNDING.yml
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
github: [cedarcode]
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
name: Release
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
release:
|
|
5
|
+
types: [published]
|
|
6
|
+
|
|
7
|
+
jobs:
|
|
8
|
+
release:
|
|
9
|
+
name: Publish to Rubygems
|
|
10
|
+
|
|
11
|
+
runs-on: ubuntu-latest
|
|
12
|
+
|
|
13
|
+
environment: release
|
|
14
|
+
|
|
15
|
+
permissions:
|
|
16
|
+
contents: write
|
|
17
|
+
id-token: write
|
|
18
|
+
|
|
19
|
+
steps:
|
|
20
|
+
- uses: actions/checkout@v6
|
|
21
|
+
|
|
22
|
+
- name: Setup Ruby
|
|
23
|
+
uses: ruby/setup-ruby@v1
|
|
24
|
+
with:
|
|
25
|
+
bundler-cache: true
|
|
26
|
+
|
|
27
|
+
- name: Publish to RubyGems
|
|
28
|
+
uses: rubygems/release-gem@v1
|
data/.github/workflows/ruby.yml
CHANGED
|
@@ -32,6 +32,7 @@ jobs:
|
|
|
32
32
|
- 2.7
|
|
33
33
|
gemfile:
|
|
34
34
|
- rails_edge
|
|
35
|
+
- rails_8_1
|
|
35
36
|
- rails_8_0
|
|
36
37
|
- rails_7_2
|
|
37
38
|
- rails_7_1
|
|
@@ -39,11 +40,15 @@ jobs:
|
|
|
39
40
|
exclude:
|
|
40
41
|
- ruby: 3.1
|
|
41
42
|
gemfile: rails_edge
|
|
43
|
+
- ruby: 3.1
|
|
44
|
+
gemfile: rails_8_1
|
|
42
45
|
- ruby: 3.1
|
|
43
46
|
gemfile: rails_8_0
|
|
44
47
|
|
|
45
48
|
- ruby: 3.0
|
|
46
49
|
gemfile: rails_edge
|
|
50
|
+
- ruby: 3.0
|
|
51
|
+
gemfile: rails_8_1
|
|
47
52
|
- ruby: 3.0
|
|
48
53
|
gemfile: rails_8_0
|
|
49
54
|
- ruby: 3.0
|
|
@@ -51,6 +56,8 @@ jobs:
|
|
|
51
56
|
|
|
52
57
|
- ruby: 2.7
|
|
53
58
|
gemfile: rails_edge
|
|
59
|
+
- ruby: 2.7
|
|
60
|
+
gemfile: rails_8_1
|
|
54
61
|
- ruby: 2.7
|
|
55
62
|
gemfile: rails_8_0
|
|
56
63
|
- ruby: 2.7
|
data/.rubocop.yml
CHANGED
data/Appraisals
CHANGED
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## Unreleased
|
|
4
|
+
|
|
5
|
+
## [v0.2.1](https://github.com/cedarcode/devise-webauthn/compare/v0.2.0...v0.2.1/) - 2025-12-10
|
|
6
|
+
|
|
7
|
+
- Add form helpers for security key registration and 2FA authentication.
|
|
8
|
+
- Fix incorrect call to `resource_name` instead of using passed `resource` param in `login_with_security_key_button` helper.
|
|
9
|
+
- Fix `NoMethodError` when calling `second_factor_enabled?` on resources without 2FA.
|
|
10
|
+
- Avoid assuming `email` as the authentication key of the resource in form helpers.
|
|
11
|
+
|
|
12
|
+
## [v0.2.0](https://github.com/cedarcode/devise-webauthn/compare/v0.1.2...v0.2.0/) - 2025-12-03
|
|
13
|
+
|
|
14
|
+
- Add new `webauthn_two_factor_authenticatable` module for enabling 2FA using WebAuthn credentials.
|
|
15
|
+
|
|
3
16
|
## [v0.1.2](https://github.com/cedarcode/devise-webauthn/compare/v0.1.1...v0.1.2/) - 2025-12-03
|
|
4
17
|
|
|
5
18
|
### Fixed
|
data/Gemfile.lock
CHANGED
data/README.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# Devise::Webauthn
|
|
2
2
|
[](https://badge.fury.io/rb/devise-webauthn)
|
|
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
|
+
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) and use [security keys](https://www.w3.org/TR/webauthn-3/#server-side-credential) for two factor authentication.
|
|
5
5
|
|
|
6
6
|
## Requirements
|
|
7
7
|
|
|
@@ -54,11 +54,12 @@ Then, follow these steps to integrate Devise::Webauthn:
|
|
|
54
54
|
```
|
|
55
55
|
|
|
56
56
|
3. **Update Your Devise Model:**
|
|
57
|
-
Add `:passkey_authenticatable` to your Devise model (e.g., `User`):
|
|
57
|
+
Add `:passkey_authenticatable` to your Devise model (e.g., `User`) for passkeys authentication and `:webauthn_two_factor_authenticatable` for WebAuthn-based 2FA if desired. For example:
|
|
58
58
|
```ruby
|
|
59
59
|
class User < ApplicationRecord
|
|
60
60
|
devise :database_authenticatable, :registerable,
|
|
61
|
-
:recoverable, :rememberable, :validatable,
|
|
61
|
+
:recoverable, :rememberable, :validatable,
|
|
62
|
+
:passkey_authenticatable, :webauthn_two_factor_authenticatable
|
|
62
63
|
end
|
|
63
64
|
```
|
|
64
65
|
|
|
@@ -77,10 +78,12 @@ Then, follow these steps to integrate Devise::Webauthn:
|
|
|
77
78
|
|
|
78
79
|
## How It Works
|
|
79
80
|
|
|
80
|
-
###
|
|
81
|
+
### Passkey authentication
|
|
82
|
+
|
|
83
|
+
#### Adding Passkeys
|
|
81
84
|
Signed-in users can add passkeys by visiting `/users/passkeys/new`.
|
|
82
85
|
|
|
83
|
-
|
|
86
|
+
#### Sign In with Passkeys
|
|
84
87
|
When a user visits `/users/sign_in` they can choose to authenticate using a passkey. The authentication flow is handled by `PasskeysAuthenticatable` strategy.
|
|
85
88
|
|
|
86
89
|
The WebAuthn passkey sign-in flow works as follows:
|
|
@@ -89,6 +92,22 @@ The WebAuthn passkey sign-in flow works as follows:
|
|
|
89
92
|
3. User selects a passkey and verifies with their [authenticator](https://www.w3.org/TR/webauthn-3/#webauthn-authenticator).
|
|
90
93
|
4. The server verifies the response and signs in the user.
|
|
91
94
|
|
|
95
|
+
### Two-Factor Authentication (2FA) with WebAuthn
|
|
96
|
+
|
|
97
|
+
#### Adding Security Keys for 2FA
|
|
98
|
+
Signed-in users can add security keys by visiting `/users/second_factor_webauthn_credentials/new`.
|
|
99
|
+
|
|
100
|
+
#### 2FA Sign In with Security Keys
|
|
101
|
+
When a user that has 2FA enabled (i.e., has registered passkeys or security keys) visits `/users/sign_in`, after entering their primary credentials (e.g., email and password), they will be prompted to complete the second factor authentication using WebAuthn. The authentication flow is handled by `WebauthnTwoFactorAuthenticatable` strategy.
|
|
102
|
+
|
|
103
|
+
The two factor authentication flow with WebAuthn works as follows:
|
|
104
|
+
1. User enters their primary credentials (e.g., email and password) and submits the form.
|
|
105
|
+
2. If the user has 2FA enabled, they are redirected to a second factor authentication page.
|
|
106
|
+
3. User clicks "Use security key", starting a WebAuthn authentication ceremony.
|
|
107
|
+
4. Browser shows available credentials (which can be both passkeys and security keys).
|
|
108
|
+
5. User selects a credential and verifies with their [authenticator](https://www.w3.org/TR/webauthn-3/#webauthn-authenticator).
|
|
109
|
+
6. The server verifies the response and signs in the user.
|
|
110
|
+
|
|
92
111
|
## Customization
|
|
93
112
|
|
|
94
113
|
### Customizing Views
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Devise
|
|
4
|
+
class SecondFactorWebauthnCredentialsController < DeviseController
|
|
5
|
+
before_action :authenticate_scope!
|
|
6
|
+
|
|
7
|
+
def new; end
|
|
8
|
+
|
|
9
|
+
def create
|
|
10
|
+
security_key_from_params = WebAuthn::Credential.from_create(JSON.parse(params[:public_key_credential]))
|
|
11
|
+
|
|
12
|
+
if verify_and_save_security_key(security_key_from_params)
|
|
13
|
+
set_flash_message! :notice, :security_key_created
|
|
14
|
+
else
|
|
15
|
+
set_flash_message! :alert, :webauthn_credential_verification_failed, scope: :"devise.failure"
|
|
16
|
+
end
|
|
17
|
+
redirect_to after_update_path
|
|
18
|
+
rescue WebAuthn::Error
|
|
19
|
+
set_flash_message! :alert, :webauthn_credential_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.second_factor_webauthn_credentials.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_security_key(security_key_from_params)
|
|
38
|
+
security_key_from_params.verify(
|
|
39
|
+
session[:webauthn_challenge]
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
resource.second_factor_webauthn_credentials.create(
|
|
43
|
+
external_id: security_key_from_params.id,
|
|
44
|
+
name: params[:name],
|
|
45
|
+
public_key: security_key_from_params.public_key,
|
|
46
|
+
sign_count: security_key_from_params.sign_count
|
|
47
|
+
)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# The default url to be used after creating a second factor key. You can overwrite
|
|
51
|
+
# this method in your own SecondFactorWebauthnCredentialsController.
|
|
52
|
+
def after_update_path
|
|
53
|
+
new_second_factor_webauthn_credential_path(resource_name)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Devise
|
|
4
|
+
class TwoFactorAuthenticationsController < DeviseController
|
|
5
|
+
prepend_before_action :set_resource, only: :new
|
|
6
|
+
prepend_before_action :ensure_sign_in_initiated
|
|
7
|
+
prepend_before_action :require_no_authentication
|
|
8
|
+
|
|
9
|
+
def new; end
|
|
10
|
+
|
|
11
|
+
def create
|
|
12
|
+
self.resource = warden.authenticate!(auth_options)
|
|
13
|
+
set_flash_message! :notice, :signed_in, scope: :"devise.sessions"
|
|
14
|
+
sign_in(resource_name, resource)
|
|
15
|
+
yield resource if block_given?
|
|
16
|
+
respond_with resource, location: after_sign_in_path_for(resource)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
protected
|
|
20
|
+
|
|
21
|
+
def auth_options
|
|
22
|
+
{ scope: resource_name, recall: "#{controller_path}#new", locale: I18n.locale }
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def ensure_sign_in_initiated
|
|
28
|
+
return if session[:current_authentication_resource_id].present?
|
|
29
|
+
|
|
30
|
+
set_flash_message! :alert, :sign_in_not_initiated, scope: :"devise.failure"
|
|
31
|
+
redirect_to new_session_path(resource_name)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def set_resource
|
|
35
|
+
@resource = resource_class.find(session[:current_authentication_resource_id])
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<%= login_with_security_key_button('Use security key', resource: @resource) %>
|
data/config/locales/en.yml
CHANGED
|
@@ -2,7 +2,12 @@ en:
|
|
|
2
2
|
devise:
|
|
3
3
|
passkeys:
|
|
4
4
|
passkey_created: "Passkey created successfully."
|
|
5
|
-
|
|
5
|
+
second_factor_webauthn_credentials:
|
|
6
|
+
security_key_created: "Security Key created successfully."
|
|
6
7
|
failure:
|
|
7
8
|
passkey_not_found: "Your passkey doesn't exist or is not valid."
|
|
8
9
|
passkey_verification_failed: "Passkey verification failed."
|
|
10
|
+
sign_in_not_initiated: "Sign in was not initiated."
|
|
11
|
+
two_factor_required: "Two-factor authentication is required to sign in."
|
|
12
|
+
webauthn_credential_not_found: "Your WebAuthn credential doesn't exist or is not valid."
|
|
13
|
+
webauthn_credential_verification_failed: "Webauthn credential verification failed."
|
|
@@ -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.1"
|
|
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: "../"
|
|
@@ -1,21 +1,17 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "active_support/concern"
|
|
4
|
+
require "devise/models/webauthn_credential_authenticatable"
|
|
4
5
|
require "devise/strategies/passkey_authenticatable"
|
|
5
6
|
|
|
6
7
|
module Devise
|
|
7
8
|
module Models
|
|
8
9
|
module PasskeyAuthenticatable
|
|
9
10
|
extend ActiveSupport::Concern
|
|
11
|
+
include WebauthnCredentialAuthenticatable
|
|
10
12
|
|
|
11
13
|
included do
|
|
12
|
-
has_many :passkeys,
|
|
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
|
|
14
|
+
has_many :passkeys, -> { passkey }, class_name: "WebauthnCredential"
|
|
19
15
|
end
|
|
20
16
|
end
|
|
21
17
|
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support/concern"
|
|
4
|
+
|
|
5
|
+
module Devise
|
|
6
|
+
module Models
|
|
7
|
+
module WebauthnCredentialAuthenticatable
|
|
8
|
+
extend ActiveSupport::Concern
|
|
9
|
+
|
|
10
|
+
included do
|
|
11
|
+
has_many :webauthn_credentials, dependent: :destroy
|
|
12
|
+
|
|
13
|
+
validates :webauthn_id, uniqueness: true, allow_blank: true
|
|
14
|
+
|
|
15
|
+
after_initialize do
|
|
16
|
+
self.webauthn_id ||= WebAuthn.generate_user_id
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support/concern"
|
|
4
|
+
require "devise/models/webauthn_credential_authenticatable"
|
|
5
|
+
require "devise/strategies/webauthn_two_factor_authenticatable"
|
|
6
|
+
|
|
7
|
+
module Devise
|
|
8
|
+
module Models
|
|
9
|
+
module WebauthnTwoFactorAuthenticatable
|
|
10
|
+
extend ActiveSupport::Concern
|
|
11
|
+
include WebauthnCredentialAuthenticatable
|
|
12
|
+
|
|
13
|
+
included do
|
|
14
|
+
has_many :second_factor_webauthn_credentials, -> { second_factor }, class_name: "WebauthnCredential"
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def second_factor_enabled?
|
|
18
|
+
webauthn_credentials.any?
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'devise/strategies/authenticatable'
|
|
4
|
+
|
|
5
|
+
module Devise
|
|
6
|
+
module Strategies
|
|
7
|
+
# Default strategy for signing in a user, based on their email and password in the database.
|
|
8
|
+
class DatabaseAuthenticatable < Authenticatable
|
|
9
|
+
def authenticate!
|
|
10
|
+
resource = password.present? && mapping.to.find_for_database_authentication(authentication_hash)
|
|
11
|
+
hashed = false
|
|
12
|
+
|
|
13
|
+
if validate(resource){ hashed = true; resource.valid_password?(password) }
|
|
14
|
+
if second_factor_enabled?(resource)
|
|
15
|
+
session[:current_authentication_resource_id] = resource.id
|
|
16
|
+
request.flash[:notice] = two_factor_required_message
|
|
17
|
+
request.commit_flash
|
|
18
|
+
redirect!(two_factor_authentication_path, {}, message: two_factor_required_message)
|
|
19
|
+
else
|
|
20
|
+
remember_me(resource)
|
|
21
|
+
resource.after_database_authentication
|
|
22
|
+
success!(resource)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# In paranoid mode, hash the password even when a resource doesn't exist for the given authentication key.
|
|
27
|
+
# This is necessary to prevent enumeration attacks - e.g. the request is faster when a resource doesn't
|
|
28
|
+
# exist in the database if the password hashing algorithm is not called.
|
|
29
|
+
mapping.to.new.password = password if !hashed && Devise.paranoid
|
|
30
|
+
unless resource
|
|
31
|
+
Devise.paranoid ? fail(:invalid) : fail(:not_found_in_database)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def two_factor_authentication_path
|
|
38
|
+
Rails.application.routes.url_helpers.send(:"new_#{scope}_two_factor_authentication_path")
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def two_factor_required_message
|
|
42
|
+
I18n.t(:"#{scope}.two_factor_required", resource_name: scope, scope: "devise.failure", default: :two_factor_required)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def second_factor_enabled?(resource)
|
|
46
|
+
resource.respond_to?(:second_factor_enabled?) && resource.second_factor_enabled?
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
Warden::Strategies.add(:database_authenticatable, Devise::Strategies::DatabaseAuthenticatable)
|
|
@@ -9,7 +9,7 @@ module Devise
|
|
|
9
9
|
|
|
10
10
|
def authenticate!
|
|
11
11
|
passkey_from_params = WebAuthn::Credential.from_get(JSON.parse(passkey_param))
|
|
12
|
-
stored_passkey = WebauthnCredential.find_by(external_id: passkey_from_params.id)
|
|
12
|
+
stored_passkey = WebauthnCredential.passkey.find_by(external_id: passkey_from_params.id)
|
|
13
13
|
|
|
14
14
|
return fail!(:passkey_not_found) if stored_passkey.blank?
|
|
15
15
|
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Devise
|
|
4
|
+
module Strategies
|
|
5
|
+
class WebauthnTwoFactorAuthenticatable < Devise::Strategies::Base
|
|
6
|
+
def valid?
|
|
7
|
+
credential_param.present? && session[:two_factor_authentication_challenge].present?
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def authenticate!
|
|
11
|
+
credential_from_params = WebAuthn::Credential.from_get(JSON.parse(credential_param))
|
|
12
|
+
stored_credential = WebauthnCredential.find_by(external_id: credential_from_params.id)
|
|
13
|
+
|
|
14
|
+
return fail!(:webauthn_credential_not_found) if stored_credential.blank?
|
|
15
|
+
|
|
16
|
+
verify_credential(credential_from_params, stored_credential)
|
|
17
|
+
|
|
18
|
+
resource = stored_credential.public_send(resource_name)
|
|
19
|
+
success!(resource)
|
|
20
|
+
|
|
21
|
+
session.delete(:current_authentication_resource_id)
|
|
22
|
+
rescue WebAuthn::Error
|
|
23
|
+
fail!(:webauthn_credential_verification_failed)
|
|
24
|
+
ensure
|
|
25
|
+
session.delete(:two_factor_authentication_challenge)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def credential_param
|
|
31
|
+
params[:public_key_credential]
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def verify_credential(credential_from_params, stored_credential)
|
|
35
|
+
credential_from_params.verify(
|
|
36
|
+
session[:two_factor_authentication_challenge],
|
|
37
|
+
public_key: stored_credential.public_key,
|
|
38
|
+
sign_count: stored_credential.sign_count
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
stored_credential.update!(sign_count: credential_from_params.sign_count)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def resource_name
|
|
45
|
+
mapping.to.name.underscore
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
Warden::Strategies.add(:webauthn_two_factor_authenticatable, Devise::Strategies::WebauthnTwoFactorAuthenticatable)
|
|
@@ -14,6 +14,15 @@ module Devise
|
|
|
14
14
|
route: { passkey_authentication: [] }
|
|
15
15
|
}
|
|
16
16
|
)
|
|
17
|
+
|
|
18
|
+
Devise.add_module(
|
|
19
|
+
:webauthn_two_factor_authenticatable,
|
|
20
|
+
{
|
|
21
|
+
model: "devise/models/webauthn_two_factor_authenticatable",
|
|
22
|
+
strategy: true,
|
|
23
|
+
route: { two_factor_authentication: [] }
|
|
24
|
+
}
|
|
25
|
+
)
|
|
17
26
|
end
|
|
18
27
|
|
|
19
28
|
initializer "devise.webauthn.helpers" do
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
# rubocop:disable Metrics/ModuleLength
|
|
3
4
|
module Devise
|
|
4
5
|
module Webauthn
|
|
5
6
|
module CredentialsHelper
|
|
@@ -27,7 +28,41 @@ module Devise
|
|
|
27
28
|
data: {
|
|
28
29
|
action: "webauthn-credentials#get:prevent",
|
|
29
30
|
controller: "webauthn-credentials",
|
|
30
|
-
webauthn_credentials_options_param:
|
|
31
|
+
webauthn_credentials_options_param: passkey_authentication_options
|
|
32
|
+
},
|
|
33
|
+
class: form_classes
|
|
34
|
+
) do |f|
|
|
35
|
+
concat f.hidden_field(:public_key_credential,
|
|
36
|
+
data: { "webauthn-credentials-target": "credentialHiddenInput" })
|
|
37
|
+
concat f.button(text, type: "submit", class: button_classes, &block)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def security_key_creation_form_for(resource, form_classes: nil, &block)
|
|
42
|
+
form_with(
|
|
43
|
+
url: second_factor_webauthn_credentials_path(resource),
|
|
44
|
+
method: :post,
|
|
45
|
+
class: form_classes,
|
|
46
|
+
data: {
|
|
47
|
+
action: "webauthn-credentials#create:prevent",
|
|
48
|
+
controller: "webauthn-credentials",
|
|
49
|
+
webauthn_credentials_options_param: create_security_key_options(resource)
|
|
50
|
+
}
|
|
51
|
+
) do |f|
|
|
52
|
+
concat f.hidden_field(:public_key_credential,
|
|
53
|
+
data: { "webauthn-credentials-target": "credentialHiddenInput" })
|
|
54
|
+
concat capture(f, &block)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def login_with_security_key_button(text = nil, resource:, button_classes: nil, form_classes: nil, &block)
|
|
59
|
+
form_with(
|
|
60
|
+
url: two_factor_authentication_path(resource),
|
|
61
|
+
method: :post,
|
|
62
|
+
data: {
|
|
63
|
+
action: "webauthn-credentials#get:prevent",
|
|
64
|
+
controller: "webauthn-credentials",
|
|
65
|
+
webauthn_credentials_options_param: security_key_authentication_options(resource)
|
|
31
66
|
},
|
|
32
67
|
class: form_classes
|
|
33
68
|
) do |f|
|
|
@@ -44,7 +79,7 @@ module Devise
|
|
|
44
79
|
options = WebAuthn::Credential.options_for_create(
|
|
45
80
|
user: {
|
|
46
81
|
id: resource.webauthn_id,
|
|
47
|
-
name:
|
|
82
|
+
name: resource_human_palatable_identifier
|
|
48
83
|
},
|
|
49
84
|
exclude: resource.passkeys.pluck(:external_id),
|
|
50
85
|
authenticator_selection: {
|
|
@@ -60,8 +95,8 @@ module Devise
|
|
|
60
95
|
end
|
|
61
96
|
end
|
|
62
97
|
|
|
63
|
-
def
|
|
64
|
-
@
|
|
98
|
+
def passkey_authentication_options
|
|
99
|
+
@passkey_authentication_options ||= begin
|
|
65
100
|
options = WebAuthn::Credential.options_for_get(
|
|
66
101
|
user_verification: "required"
|
|
67
102
|
)
|
|
@@ -72,6 +107,49 @@ module Devise
|
|
|
72
107
|
options
|
|
73
108
|
end
|
|
74
109
|
end
|
|
110
|
+
|
|
111
|
+
def create_security_key_options(resource)
|
|
112
|
+
@create_security_key_options ||= begin
|
|
113
|
+
options = WebAuthn::Credential.options_for_create(
|
|
114
|
+
user: {
|
|
115
|
+
id: resource.webauthn_id,
|
|
116
|
+
name: resource_human_palatable_identifier
|
|
117
|
+
},
|
|
118
|
+
exclude: resource.webauthn_credentials.pluck(:external_id),
|
|
119
|
+
authenticator_selection: {
|
|
120
|
+
resident_key: "discouraged",
|
|
121
|
+
user_verification: "discouraged"
|
|
122
|
+
}
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
# Store challenge in session for later verification
|
|
126
|
+
session[:webauthn_challenge] = options.challenge
|
|
127
|
+
|
|
128
|
+
options
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def security_key_authentication_options(resource)
|
|
133
|
+
@security_key_authentication_options ||= begin
|
|
134
|
+
options = WebAuthn::Credential.options_for_get(
|
|
135
|
+
allow: resource.webauthn_credentials.pluck(:external_id),
|
|
136
|
+
user_verification: "discouraged"
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
# Store challenge in session for later verification
|
|
140
|
+
session[:two_factor_authentication_challenge] = options.challenge
|
|
141
|
+
|
|
142
|
+
options
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def resource_human_palatable_identifier
|
|
147
|
+
authentication_keys = resource.class.authentication_keys
|
|
148
|
+
authentication_keys = authentication_keys.keys if authentication_keys.is_a?(Hash)
|
|
149
|
+
|
|
150
|
+
authentication_keys.filter_map { |authentication_key| resource.public_send(authentication_key) }.first
|
|
151
|
+
end
|
|
75
152
|
end
|
|
76
153
|
end
|
|
77
154
|
end
|
|
155
|
+
# rubocop:enable Metrics/ModuleLength
|
|
@@ -8,6 +8,16 @@ module ActionDispatch
|
|
|
8
8
|
def devise_passkey_authentication(_mapping, controllers)
|
|
9
9
|
resources :passkeys, only: %i[new create destroy], controller: controllers[:passkeys]
|
|
10
10
|
end
|
|
11
|
+
|
|
12
|
+
def devise_two_factor_authentication(_mapping, controllers)
|
|
13
|
+
resource :two_factor_authentication,
|
|
14
|
+
only: %i[new create],
|
|
15
|
+
controller: controllers[:two_factor_authentications]
|
|
16
|
+
|
|
17
|
+
resources :second_factor_webauthn_credentials,
|
|
18
|
+
only: %i[new create destroy],
|
|
19
|
+
controller: controllers[:second_factor_webauthn_credentials]
|
|
20
|
+
end
|
|
11
21
|
end
|
|
12
22
|
end
|
|
13
23
|
end
|
|
@@ -23,7 +23,10 @@ module Devise
|
|
|
23
23
|
module UrlHelpers
|
|
24
24
|
{
|
|
25
25
|
passkeys: [nil],
|
|
26
|
-
passkey: [nil, :new]
|
|
26
|
+
passkey: [nil, :new],
|
|
27
|
+
two_factor_authentication: [nil, :new],
|
|
28
|
+
second_factor_webauthn_credentials: [nil],
|
|
29
|
+
second_factor_webauthn_credential: [nil, :new]
|
|
27
30
|
}.each do |route, actions|
|
|
28
31
|
%i[path url].each do |path_or_url|
|
|
29
32
|
actions.each do |action|
|
data/lib/generators/devise/webauthn/webauthn_credential_model/webauthn_credential_model_generator.rb
CHANGED
|
@@ -19,7 +19,8 @@ module Devise
|
|
|
19
19
|
"name:string",
|
|
20
20
|
"public_key:text",
|
|
21
21
|
"sign_count:integer{8}",
|
|
22
|
-
"#{user_model_name}:references"
|
|
22
|
+
"#{user_model_name}:references",
|
|
23
|
+
"authentication_factor:integer{1}"
|
|
23
24
|
]
|
|
24
25
|
end
|
|
25
26
|
|
|
@@ -28,6 +29,10 @@ module Devise
|
|
|
28
29
|
<<~RUBY.indent(2)
|
|
29
30
|
validates :external_id, :public_key, :name, :sign_count, presence: true
|
|
30
31
|
validates :external_id, uniqueness: true
|
|
32
|
+
|
|
33
|
+
enum :authentication_factor, { first_factor: 0, second_factor: 1 }
|
|
34
|
+
|
|
35
|
+
scope :passkey, -> { first_factor }
|
|
31
36
|
RUBY
|
|
32
37
|
end
|
|
33
38
|
end
|
metadata
CHANGED
|
@@ -1,14 +1,13 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: devise-webauthn
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.1
|
|
4
|
+
version: 0.2.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Cedarcode
|
|
8
|
-
autorequire:
|
|
9
8
|
bindir: exe
|
|
10
9
|
cert_chain: []
|
|
11
|
-
date:
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
12
11
|
dependencies:
|
|
13
12
|
- !ruby/object:Gem::Dependency
|
|
14
13
|
name: devise
|
|
@@ -38,13 +37,14 @@ dependencies:
|
|
|
38
37
|
- - "~>"
|
|
39
38
|
- !ruby/object:Gem::Version
|
|
40
39
|
version: '3.0'
|
|
41
|
-
description:
|
|
42
40
|
email:
|
|
43
41
|
- webauthn@cedarcode.com
|
|
44
42
|
executables: []
|
|
45
43
|
extensions: []
|
|
46
44
|
extra_rdoc_files: []
|
|
47
45
|
files:
|
|
46
|
+
- ".github/FUNDING.yml"
|
|
47
|
+
- ".github/workflows/release.yml"
|
|
48
48
|
- ".github/workflows/ruby.yml"
|
|
49
49
|
- ".gitignore"
|
|
50
50
|
- ".rspec"
|
|
@@ -58,8 +58,12 @@ files:
|
|
|
58
58
|
- README.md
|
|
59
59
|
- Rakefile
|
|
60
60
|
- app/controllers/devise/passkeys_controller.rb
|
|
61
|
+
- app/controllers/devise/second_factor_webauthn_credentials_controller.rb
|
|
62
|
+
- app/controllers/devise/two_factor_authentications_controller.rb
|
|
61
63
|
- app/views/devise/passkeys/new.html.erb
|
|
64
|
+
- app/views/devise/second_factor_webauthn_credentials/new.html.erb
|
|
62
65
|
- app/views/devise/sessions/new.html.erb
|
|
66
|
+
- app/views/devise/two_factor_authentications/new.html.erb
|
|
63
67
|
- bin/console
|
|
64
68
|
- bin/rails
|
|
65
69
|
- bin/setup
|
|
@@ -69,9 +73,14 @@ files:
|
|
|
69
73
|
- gemfiles/rails_7_1.gemfile
|
|
70
74
|
- gemfiles/rails_7_2.gemfile
|
|
71
75
|
- gemfiles/rails_8_0.gemfile
|
|
76
|
+
- gemfiles/rails_8_1.gemfile
|
|
72
77
|
- gemfiles/rails_edge.gemfile
|
|
73
78
|
- lib/devise/models/passkey_authenticatable.rb
|
|
79
|
+
- lib/devise/models/webauthn_credential_authenticatable.rb
|
|
80
|
+
- lib/devise/models/webauthn_two_factor_authenticatable.rb
|
|
81
|
+
- lib/devise/strategies/database_authenticatable.rb
|
|
74
82
|
- lib/devise/strategies/passkey_authenticatable.rb
|
|
83
|
+
- lib/devise/strategies/webauthn_two_factor_authenticatable.rb
|
|
75
84
|
- lib/devise/webauthn.rb
|
|
76
85
|
- lib/devise/webauthn/engine.rb
|
|
77
86
|
- lib/devise/webauthn/helpers/credentials_helper.rb
|
|
@@ -97,7 +106,6 @@ metadata:
|
|
|
97
106
|
source_code_uri: https://github.com/cedarcode/devise-webauthn
|
|
98
107
|
changelog_uri: https://github.com/cedarcode/devise-webauthn/blob/master/CHANGELOG.md
|
|
99
108
|
rubygems_mfa_required: 'true'
|
|
100
|
-
post_install_message:
|
|
101
109
|
rdoc_options: []
|
|
102
110
|
require_paths:
|
|
103
111
|
- lib
|
|
@@ -112,8 +120,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
112
120
|
- !ruby/object:Gem::Version
|
|
113
121
|
version: '0'
|
|
114
122
|
requirements: []
|
|
115
|
-
rubygems_version: 3.
|
|
116
|
-
signing_key:
|
|
123
|
+
rubygems_version: 3.6.7
|
|
117
124
|
specification_version: 4
|
|
118
125
|
summary: Devise extension to support WebAuthn.
|
|
119
126
|
test_files: []
|