devise-webauthn 0.1.1 → 0.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 91f62778d1c97e17cb2fcf97d8f6dcb91716eb70f7c304f662de39c4861c2273
4
- data.tar.gz: f64c93b4af61569057b475b2ab2f4e4604bf1c7666f3eeda34d51993e96dfbc6
3
+ metadata.gz: 5b2e30cdcc561dc53baf71ada282183019321b12b1820afc6107b67c000c1297
4
+ data.tar.gz: f01204525d77a874a131b4a4f13928faa9d6819160792bc12efdb0ad9b9b6d5b
5
5
  SHA512:
6
- metadata.gz: 1ba0a89c63ac5e1255fd657e9b6710167ff8932c700bd29b81dbfef32ed3f5fb1832a40766be2882f9e3548b49ab67e763de479a6ebe58f898869336d5e8d4ad
7
- data.tar.gz: bff709a1283d67c0289ac21f3a1d31cd8e486202dfb973ac0450636129dcfe1cd467bfceacbfa3b84b8c7505b3b1c0e4eaa374c0f897a19c8c8e8d4a962937ae
6
+ metadata.gz: 3c4c14744bbaeb2ed55957baa3d868dd5eb4ebb48300a13a17cd110c329a373f18f97706da1d08f2cfe48d09d5e7a96d0d409c8af485658a55c357f121dbadde
7
+ data.tar.gz: 3456027c51e2aab1434a47afe3943e4e75910d38bba9e0dc551662cc0a0f4708d52d108e30229fe390f93f89e8d411d7be669cc9c2ffdb31dea2aa41aa280669
data/.rubocop.yml CHANGED
@@ -9,6 +9,7 @@ AllCops:
9
9
  - "spec/internal/**/*"
10
10
  - "vendor/**/*"
11
11
  - "gemfiles/**/*"
12
+ - "lib/devise/strategies/database_authenticatable.rb"
12
13
 
13
14
  Style/Documentation:
14
15
  Enabled: false
data/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## Unreleased
4
+
5
+ ## [v0.2.0](https://github.com/cedarcode/devise-webauthn/compare/v0.1.2...v0.2.0/) - 2025-12-03
6
+
7
+ - Add new `webauthn_two_factor_authenticatable` module for enabling 2FA using WebAuthn credentials.
8
+
9
+ ## [v0.1.2](https://github.com/cedarcode/devise-webauthn/compare/v0.1.1...v0.1.2/) - 2025-12-03
10
+
11
+ ### Fixed
12
+
13
+ - Fixed sign in with passkey for resources with name different from "User"
14
+
3
15
  ## [v0.1.1](https://github.com/cedarcode/devise-webauthn/compare/v0.1.0...v0.1.1/) - 2025-11-13
4
16
 
5
17
  ### Changed
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- devise-webauthn (0.1.1)
4
+ devise-webauthn (0.2.0)
5
5
  devise (~> 4.9)
6
6
  webauthn (~> 3.0)
7
7
 
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Devise
4
+ class SecondFactorWebauthnCredentialsController < DeviseController
5
+ before_action :authenticate_scope!
6
+
7
+ def new
8
+ @options = WebAuthn::Credential.options_for_create(
9
+ user: {
10
+ id: resource.webauthn_id,
11
+ name: resource.email
12
+ },
13
+ exclude: resource.webauthn_credentials.pluck(:external_id),
14
+ authenticator_selection: {
15
+ resident_key: "discouraged",
16
+ user_verification: "discouraged"
17
+ }
18
+ )
19
+
20
+ # Store challenge in session for later verification
21
+ session[:webauthn_challenge] = @options.challenge
22
+ end
23
+
24
+ def create
25
+ security_key_from_params = WebAuthn::Credential.from_create(JSON.parse(params[:public_key_credential]))
26
+
27
+ if verify_and_save_security_key(security_key_from_params)
28
+ set_flash_message! :notice, :security_key_created
29
+ else
30
+ set_flash_message! :alert, :webauthn_credential_verification_failed, scope: :"devise.failure"
31
+ end
32
+ redirect_to after_update_path
33
+ rescue WebAuthn::Error
34
+ set_flash_message! :alert, :webauthn_credential_verification_failed, scope: :"devise.failure"
35
+ redirect_to after_update_path
36
+ ensure
37
+ session.delete(:webauthn_challenge)
38
+ end
39
+
40
+ def destroy
41
+ resource.second_factor_webauthn_credentials.destroy(params[:id])
42
+ redirect_to after_update_path
43
+ end
44
+
45
+ private
46
+
47
+ def authenticate_scope!
48
+ send(:"authenticate_#{resource_name}!", force: true)
49
+ self.resource = send(:"current_#{resource_name}")
50
+ end
51
+
52
+ def verify_and_save_security_key(security_key_from_params)
53
+ security_key_from_params.verify(
54
+ session[:webauthn_challenge]
55
+ )
56
+
57
+ resource.second_factor_webauthn_credentials.create(
58
+ external_id: security_key_from_params.id,
59
+ name: params[:name],
60
+ public_key: security_key_from_params.public_key,
61
+ sign_count: security_key_from_params.sign_count
62
+ )
63
+ end
64
+
65
+ # The default url to be used after creating a second factor key. You can overwrite
66
+ # this method in your own SecondFactorWebauthnCredentialsController.
67
+ def after_update_path
68
+ new_second_factor_webauthn_credential_path(resource_name)
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,44 @@
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
10
+ @options = WebAuthn::Credential.options_for_get(
11
+ allow: @resource.webauthn_credentials.pluck(:external_id),
12
+ user_verification: "discouraged"
13
+ )
14
+ session[:two_factor_authentication_challenge] = @options.challenge
15
+ end
16
+
17
+ def create
18
+ self.resource = warden.authenticate!(auth_options)
19
+ set_flash_message! :notice, :signed_in, scope: :"devise.sessions"
20
+ sign_in(resource_name, resource)
21
+ yield resource if block_given?
22
+ respond_with resource, location: after_sign_in_path_for(resource)
23
+ end
24
+
25
+ protected
26
+
27
+ def auth_options
28
+ { scope: resource_name, recall: "#{controller_path}#new", locale: I18n.locale }
29
+ end
30
+
31
+ private
32
+
33
+ def ensure_sign_in_initiated
34
+ return if session[:current_authentication_resource_id].present?
35
+
36
+ set_flash_message! :alert, :sign_in_not_initiated, scope: :"devise.failure"
37
+ redirect_to new_session_path(resource_name)
38
+ end
39
+
40
+ def set_resource
41
+ @resource = resource_class.find(session[:current_authentication_resource_id])
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,15 @@
1
+ <%= form_with(
2
+ url: second_factor_webauthn_credentials_path(resource),
3
+ method: :post,
4
+ data: {
5
+ action: "webauthn-credentials#create:prevent",
6
+ controller: "webauthn-credentials",
7
+ webauthn_credentials_options_param: @options,
8
+ }
9
+ ) do |form| %>
10
+ <%= form.hidden_field(:public_key_credential,
11
+ data: { "webauthn-credentials-target": "credentialHiddenInput" }) %>
12
+ <%= form.label :name, 'Security Key name' %>
13
+ <%= form.text_field :name, required: true %>
14
+ <%= form.submit 'Create Security Key' %>
15
+ <% end %>
@@ -0,0 +1,12 @@
1
+ <%= form_with(
2
+ url: two_factor_authentication_path(resource_name),
3
+ method: :post,
4
+ data: {
5
+ action: "webauthn-credentials#get:prevent",
6
+ controller: "webauthn-credentials",
7
+ webauthn_credentials_options_param: @options
8
+ },
9
+ ) do |f| %>
10
+ <%= f.hidden_field(:public_key_credential, data: { "webauthn-credentials-target": "credentialHiddenInput" }) %>
11
+ <%= f.button('Use security key', type: "submit") %>
12
+ <% end %>
@@ -3,6 +3,12 @@ en:
3
3
  passkeys:
4
4
  passkey_created: "Passkey created successfully."
5
5
  passkey_creation_failed: "Passkey creation failed."
6
+ second_factor_webauthn_credentials:
7
+ security_key_created: "Security Key created successfully."
6
8
  failure:
7
9
  passkey_not_found: "Your passkey doesn't exist or is not valid."
8
10
  passkey_verification_failed: "Passkey verification failed."
11
+ sign_in_not_initiated: "Sign in was not initiated."
12
+ two_factor_required: "Two-factor authentication is required to sign in."
13
+ webauthn_credential_not_found: "Your WebAuthn credential doesn't exist or is not valid."
14
+ webauthn_credential_verification_failed: "Webauthn credential verification failed."
@@ -16,7 +16,7 @@ Gem::Specification.new do |spec|
16
16
  spec.homepage = "https://github.com/cedarcode/devise-webauthn"
17
17
  spec.metadata["homepage_uri"] = spec.homepage
18
18
  spec.metadata["source_code_uri"] = spec.homepage
19
- spec.metadata["changelog_uri"] = "#{spec.homepage}/blob/main/CHANGELOG.md"
19
+ spec.metadata["changelog_uri"] = "#{spec.homepage}/blob/master/CHANGELOG.md"
20
20
 
21
21
  # Specify which files should be added to the gem when it is released.
22
22
  # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
@@ -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, 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
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,48 @@
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 resource.second_factor_enabled?
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
+ end
45
+ end
46
+ end
47
+
48
+ Warden::Strategies.add(:database_authenticatable, Devise::Strategies::DatabaseAuthenticatable)
@@ -2,20 +2,21 @@
2
2
 
3
3
  module Devise
4
4
  module Strategies
5
- class PasskeyAuthenticatable < Warden::Strategies::Base
5
+ class PasskeyAuthenticatable < Devise::Strategies::Base
6
6
  def valid?
7
7
  passkey_param.present? && session[:authentication_challenge].present?
8
8
  end
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
 
16
16
  verify_passkeys(passkey_from_params, stored_passkey)
17
17
 
18
- success!(stored_passkey.user)
18
+ resource = stored_passkey.public_send(resource_name)
19
+ success!(resource)
19
20
  rescue WebAuthn::Error
20
21
  fail!(:passkey_verification_failed)
21
22
  ensure
@@ -38,6 +39,10 @@ module Devise
38
39
 
39
40
  stored_passkey.update!(sign_count: passkey_from_params.sign_count)
40
41
  end
42
+
43
+ def resource_name
44
+ mapping.to.name.underscore
45
+ end
41
46
  end
42
47
  end
43
48
  end
@@ -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
@@ -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|
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Devise
4
4
  module Webauthn
5
- VERSION = "0.1.1"
5
+ VERSION = "0.2.0"
6
6
  end
7
7
  end
@@ -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,13 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: devise-webauthn
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.1
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Cedarcode
8
+ autorequire:
8
9
  bindir: exe
9
10
  cert_chain: []
10
- date: 1980-01-02 00:00:00.000000000 Z
11
+ date: 2025-12-03 00:00:00.000000000 Z
11
12
  dependencies:
12
13
  - !ruby/object:Gem::Dependency
13
14
  name: devise
@@ -37,6 +38,7 @@ dependencies:
37
38
  - - "~>"
38
39
  - !ruby/object:Gem::Version
39
40
  version: '3.0'
41
+ description:
40
42
  email:
41
43
  - webauthn@cedarcode.com
42
44
  executables: []
@@ -56,8 +58,12 @@ files:
56
58
  - README.md
57
59
  - Rakefile
58
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
59
63
  - app/views/devise/passkeys/new.html.erb
64
+ - app/views/devise/second_factor_webauthn_credentials/new.html.erb
60
65
  - app/views/devise/sessions/new.html.erb
66
+ - app/views/devise/two_factor_authentications/new.html.erb
61
67
  - bin/console
62
68
  - bin/rails
63
69
  - bin/setup
@@ -69,7 +75,11 @@ files:
69
75
  - gemfiles/rails_8_0.gemfile
70
76
  - gemfiles/rails_edge.gemfile
71
77
  - lib/devise/models/passkey_authenticatable.rb
78
+ - lib/devise/models/webauthn_credential_authenticatable.rb
79
+ - lib/devise/models/webauthn_two_factor_authenticatable.rb
80
+ - lib/devise/strategies/database_authenticatable.rb
72
81
  - lib/devise/strategies/passkey_authenticatable.rb
82
+ - lib/devise/strategies/webauthn_two_factor_authenticatable.rb
73
83
  - lib/devise/webauthn.rb
74
84
  - lib/devise/webauthn/engine.rb
75
85
  - lib/devise/webauthn/helpers/credentials_helper.rb
@@ -93,8 +103,9 @@ licenses:
93
103
  metadata:
94
104
  homepage_uri: https://github.com/cedarcode/devise-webauthn
95
105
  source_code_uri: https://github.com/cedarcode/devise-webauthn
96
- changelog_uri: https://github.com/cedarcode/devise-webauthn/blob/main/CHANGELOG.md
106
+ changelog_uri: https://github.com/cedarcode/devise-webauthn/blob/master/CHANGELOG.md
97
107
  rubygems_mfa_required: 'true'
108
+ post_install_message:
98
109
  rdoc_options: []
99
110
  require_paths:
100
111
  - lib
@@ -109,7 +120,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
109
120
  - !ruby/object:Gem::Version
110
121
  version: '0'
111
122
  requirements: []
112
- rubygems_version: 3.6.7
123
+ rubygems_version: 3.2.1
124
+ signing_key:
113
125
  specification_version: 4
114
126
  summary: Devise extension to support WebAuthn.
115
127
  test_files: []