webauthn-rails 0.0.1 → 0.1.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.
Files changed (30) hide show
  1. checksums.yaml +4 -4
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +167 -13
  4. data/Rakefile +12 -4
  5. data/lib/generators/erb/webauthn_authentication/templates/app/views/passkeys/new.html.erb.tt +18 -0
  6. data/lib/generators/erb/webauthn_authentication/templates/app/views/second_factor_authentications/new.html.erb.tt +18 -0
  7. data/lib/generators/erb/webauthn_authentication/templates/app/views/second_factor_webauthn_credentials/new.html.erb.tt +18 -0
  8. data/lib/generators/erb/webauthn_authentication/webauthn_authentication_generator.rb +35 -0
  9. data/lib/generators/test_unit/webauthn_authentication/templates/test/controllers/passkeys_controller_test.rb +111 -0
  10. data/lib/generators/test_unit/webauthn_authentication/templates/test/controllers/webauthn_sessions_controller_test.rb +125 -0
  11. data/lib/generators/test_unit/webauthn_authentication/templates/test/system/manage_webauthn_credentials_test.rb +76 -0
  12. data/lib/generators/test_unit/webauthn_authentication/templates/test/test_helpers/virtual_authenticator_test_helper.rb +9 -0
  13. data/lib/generators/test_unit/webauthn_authentication/webauthn_authentication_generator.rb +23 -0
  14. data/lib/generators/webauthn_authentication/bundle_helper.rb +30 -0
  15. data/lib/generators/webauthn_authentication/templates/app/controllers/passkeys_controller.rb +61 -0
  16. data/lib/generators/webauthn_authentication/templates/app/controllers/second_factor_authentications_controller.rb +62 -0
  17. data/lib/generators/webauthn_authentication/templates/app/controllers/second_factor_webauthn_credentials_controller.rb +59 -0
  18. data/lib/generators/webauthn_authentication/templates/app/controllers/webauthn_sessions_controller.rb +50 -0
  19. data/lib/generators/webauthn_authentication/templates/app/javascript/controllers/webauthn_credentials_controller.js +64 -0
  20. data/lib/generators/webauthn_authentication/templates/app/models/webauthn_credential.rb +12 -0
  21. data/lib/generators/webauthn_authentication/templates/config/initializers/webauthn.rb +8 -0
  22. data/lib/generators/webauthn_authentication/webauthn_authentication_generator.rb +182 -0
  23. data/lib/tasks/webauthn/rails_tasks.rake +4 -0
  24. data/lib/webauthn/rails/version.rb +1 -3
  25. data/lib/webauthn/rails.rb +1 -5
  26. metadata +53 -18
  27. data/.rspec +0 -3
  28. data/CHANGELOG.md +0 -5
  29. data/LICENSE.txt +0 -21
  30. data/sig/webauthn/rails.rbs +0 -6
@@ -0,0 +1,23 @@
1
+ require "rails/generators/test_unit"
2
+
3
+ module TestUnit
4
+ module Generators
5
+ class WebauthnAuthenticationGenerator < Rails::Generators::Base
6
+ hide!
7
+ source_root File.expand_path("../templates", __FILE__)
8
+
9
+ def create_controller_test_files
10
+ template "test/controllers/passkeys_controller_test.rb"
11
+ template "test/controllers/webauthn_sessions_controller_test.rb"
12
+ end
13
+
14
+ def create_system_test_files
15
+ template "test/system/manage_webauthn_credentials_test.rb"
16
+ end
17
+
18
+ def create_test_helper_files
19
+ template "test/test_helpers/virtual_authenticator_test_helper.rb"
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,30 @@
1
+ # Extracted from: https://github.com/rails/rails/blob/7549ba77254f038b1ca6304a0be6cbda8e2a09c4/railties/lib/rails/generators/bundle_helper.rb
2
+
3
+ module BundleHelper # :nodoc:
4
+ def bundle_command(command, env = {}, params = {})
5
+ say_status :run, "bundle #{command}"
6
+
7
+ # We are going to shell out rather than invoking Bundler::CLI.new(command)
8
+ # because `rails new` loads the Thor gem and on the other hand bundler uses
9
+ # its own vendored Thor, which could be a different version. Running both
10
+ # things in the same process is a recipe for a night with paracetamol.
11
+ #
12
+ # Thanks to James Tucker for the Gem tricks involved in this call.
13
+ _bundle_command = Gem.bin_path("bundler", "bundle")
14
+
15
+ require "bundler"
16
+ Bundler.with_original_env do
17
+ exec_bundle_command(_bundle_command, command, env, params)
18
+ end
19
+ end
20
+
21
+ private
22
+ def exec_bundle_command(bundle_command, command, env, params)
23
+ full_command = %Q("#{Gem.ruby}" "#{bundle_command}" #{command})
24
+ if options[:quiet] || params[:quiet]
25
+ system(env, full_command, out: File::NULL)
26
+ else
27
+ system(env, full_command)
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,61 @@
1
+ class PasskeysController < ApplicationController
2
+ def create_options
3
+ create_options = WebAuthn::Credential.options_for_create(
4
+ user: {
5
+ id: Current.user.webauthn_id,
6
+ name: Current.user.email_address
7
+ },
8
+ exclude: Current.user.webauthn_credentials.pluck(:external_id),
9
+ authenticator_selection: {
10
+ resident_key: "required",
11
+ user_verification: "required"
12
+ }
13
+ )
14
+
15
+ session[:current_registration] = { challenge: create_options.challenge }
16
+
17
+ render json: create_options
18
+ end
19
+
20
+ def create
21
+ webauthn_credential = WebAuthn::Credential.from_create(JSON.parse(create_credential_params[:public_key_credential]))
22
+
23
+ begin
24
+ webauthn_credential.verify(
25
+ session[:current_registration][:challenge] || session[:current_registration]["challenge"],
26
+ user_verification: true,
27
+ )
28
+
29
+ credential = Current.user.passkeys.find_or_initialize_by(
30
+ external_id: webauthn_credential.id
31
+ )
32
+
33
+ if credential.update(
34
+ nickname: create_credential_params[:nickname],
35
+ public_key: webauthn_credential.public_key,
36
+ sign_count: webauthn_credential.sign_count
37
+ )
38
+ redirect_to root_path, notice: "Security Key registered successfully"
39
+ else
40
+ flash[:alert] = "Error registering credential"
41
+ render :new
42
+ end
43
+ rescue WebAuthn::Error => e
44
+ redirect_to new_passkey_path, alert: "Verification failed: #{e.message}"
45
+ end
46
+ ensure
47
+ session.delete(:current_registration)
48
+ end
49
+
50
+ def destroy
51
+ Current.user.passkeys.destroy(params[:id])
52
+
53
+ redirect_to root_path, notice: "Security Key deleted successfully"
54
+ end
55
+
56
+ private
57
+
58
+ def create_credential_params
59
+ params.expect(credential: [ :nickname, :public_key_credential ])
60
+ end
61
+ end
@@ -0,0 +1,62 @@
1
+ class SecondFactorAuthenticationsController < ApplicationController
2
+ allow_unauthenticated_access only: %i[new get_options create]
3
+ before_action :require_no_authentication
4
+ before_action :ensure_login_initiated
5
+
6
+ def get_options
7
+ get_options = WebAuthn::Credential.options_for_get(
8
+ allow: user.webauthn_credentials.pluck(:external_id),
9
+ user_verification: "discouraged"
10
+ )
11
+ session[:current_authentication][:challenge] = get_options.challenge
12
+
13
+ render json: get_options
14
+ end
15
+
16
+ def create
17
+ webauthn_credential = WebAuthn::Credential.from_get(JSON.parse(session_params[:public_key_credential]))
18
+
19
+ credential = user.webauthn_credentials.find_by(external_id: webauthn_credential.id)
20
+ unless credential
21
+ redirect_to new_second_factor_authentication_path, alert: "Credential not recognized"
22
+ return
23
+ end
24
+
25
+ begin
26
+ webauthn_credential.verify(
27
+ session[:current_authentication][:challenge] || session[:current_authentication]["challenge"],
28
+ public_key: credential.public_key,
29
+ sign_count: credential.sign_count
30
+ )
31
+
32
+ credential.update!(sign_count: webauthn_credential.sign_count)
33
+ start_new_session_for user
34
+
35
+ redirect_to after_authentication_url
36
+ rescue WebAuthn::Error => e
37
+ redirect_to new_second_factor_authentication_path, alert: "Verification failed: #{e.message}"
38
+ ensure
39
+ session.delete(:current_authentication)
40
+ end
41
+ end
42
+
43
+ private
44
+
45
+ def user
46
+ @user ||= User.find_by(id: current_authentication_user_id)
47
+ end
48
+
49
+ def ensure_login_initiated
50
+ if current_authentication_user_id.blank?
51
+ redirect_to new_session_path
52
+ end
53
+ end
54
+
55
+ def session_params
56
+ params.expect(session: [ :public_key_credential ])
57
+ end
58
+
59
+ def current_authentication_user_id
60
+ session[:current_authentication][:user_id] || session[:current_authentication]["user_id"]
61
+ end
62
+ end
@@ -0,0 +1,59 @@
1
+ class SecondFactorWebauthnCredentialsController < ApplicationController
2
+ def create_options
3
+ create_options = WebAuthn::Credential.options_for_create(
4
+ user: {
5
+ id: Current.user.webauthn_id,
6
+ name: Current.user.email_address
7
+ },
8
+ exclude: Current.user.webauthn_credentials.pluck(:external_id),
9
+ authenticator_selection: {
10
+ resident_key: "discouraged",
11
+ user_verification: "discouraged"
12
+ }
13
+ )
14
+
15
+ session[:current_registration] = { challenge: create_options.challenge }
16
+
17
+ render json: create_options
18
+ end
19
+
20
+ def create
21
+ webauthn_credential = WebAuthn::Credential.from_create(JSON.parse(create_credential_params[:public_key_credential]))
22
+
23
+ begin
24
+ webauthn_credential.verify(
25
+ session[:current_registration][:challenge] || session[:current_registration]["challenge"]
26
+ )
27
+
28
+ credential = Current.user.second_factor_webauthn_credentials.find_or_initialize_by(
29
+ external_id: webauthn_credential.id
30
+ )
31
+
32
+ if credential.update(
33
+ nickname: create_credential_params[:nickname],
34
+ public_key: webauthn_credential.public_key,
35
+ sign_count: webauthn_credential.sign_count
36
+ )
37
+ redirect_to root_path, notice: "Security Key registered successfully"
38
+ else
39
+ redirect_to new_second_factor_webauthn_credential_path, alert: "Error registering credential"
40
+ end
41
+ rescue WebAuthn::Error => e
42
+ redirect_to new_second_factor_webauthn_credential_path, alert: "Verification failed: #{e.message}"
43
+ ensure
44
+ session.delete(:current_registration)
45
+ end
46
+ end
47
+
48
+ def destroy
49
+ Current.user.second_factor_webauthn_credentials.destroy(params[:id])
50
+
51
+ redirect_to root_path, notice: "Security Key deleted successfully"
52
+ end
53
+
54
+ private
55
+
56
+ def create_credential_params
57
+ params.expect(credential: [ :nickname, :public_key_credential ])
58
+ end
59
+ end
@@ -0,0 +1,50 @@
1
+ class WebauthnSessionsController < ApplicationController
2
+ allow_unauthenticated_access only: %i[get_options create]
3
+
4
+ def get_options
5
+ get_options = WebAuthn::Credential.options_for_get(user_verification: "required")
6
+ session[:current_authentication] = { challenge: get_options.challenge }
7
+
8
+ render json: get_options
9
+ end
10
+
11
+ def create
12
+ webauthn_credential = WebAuthn::Credential.from_get(JSON.parse(session_params[:public_key_credential]))
13
+
14
+ stored_credential = WebauthnCredential.passkey.find_by(external_id: webauthn_credential.id)
15
+ unless stored_credential
16
+ redirect_to new_session_path, alert: "Credential not recognized"
17
+ return
18
+ end
19
+
20
+ begin
21
+ webauthn_credential.verify(
22
+ session[:current_authentication][:challenge] || session[:current_authentication]["challenge"],
23
+ public_key: stored_credential.public_key,
24
+ sign_count: stored_credential.sign_count,
25
+ user_verification: true,
26
+ )
27
+
28
+ stored_credential.update!(sign_count: webauthn_credential.sign_count)
29
+ start_new_session_for stored_credential.user
30
+
31
+ redirect_to after_authentication_url
32
+ rescue WebAuthn::Error => e
33
+ redirect_to new_session_path, alert: "Verification failed: #{e.message}"
34
+ end
35
+ ensure
36
+ session.delete(:current_authentication)
37
+ end
38
+
39
+ def destroy
40
+ terminate_session
41
+
42
+ redirect_to new_session_path
43
+ end
44
+
45
+ private
46
+
47
+ def session_params
48
+ params.expect(session: [ :public_key_credential ])
49
+ end
50
+ end
@@ -0,0 +1,64 @@
1
+ import { Controller } from "@hotwired/stimulus";
2
+ import { create as createWebAuthnJSON, get as getWebAuthnJSON } from "@github/webauthn-json/browser-ponyfill";
3
+
4
+ export default class extends Controller {
5
+ static targets = ["credentialHiddenInput", "submitButton"];
6
+ static values = { optionsUrl: String }
7
+
8
+ connect() {
9
+ this.submitButtonTarget.disabled = false;
10
+ }
11
+
12
+ async create() {
13
+ try {
14
+ const optionsResponse = await fetch(this.optionsUrlValue, {
15
+ method: "POST",
16
+ body: new FormData(this.element),
17
+ headers: {
18
+ "X-CSRF-Token": document.querySelector('meta[name="csrf-token"]')?.getAttribute("content")
19
+ },
20
+ });
21
+
22
+ const optionsJson = await optionsResponse.json();
23
+ if (optionsResponse.ok) {
24
+ const credentialOptions = PublicKeyCredential.parseCreationOptionsFromJSON(optionsJson);
25
+ const credential = await createWebAuthnJSON({ publicKey: credentialOptions });
26
+
27
+ this.credentialHiddenInputTarget.value = JSON.stringify(credential);
28
+
29
+ this.element.submit();
30
+ } else {
31
+ alert(optionsJson.errors?.[0] || "Unknown error");
32
+ }
33
+ } catch (error) {
34
+ alert(error.message || error);
35
+ }
36
+ }
37
+
38
+ async get() {
39
+ try {
40
+ const optionsResponse = await fetch(this.optionsUrlValue, {
41
+ method: "POST",
42
+ body: new FormData(this.element),
43
+ headers: {
44
+ "X-CSRF-Token": document.querySelector('meta[name="csrf-token"]')?.getAttribute("content")
45
+ },
46
+ });
47
+
48
+ const optionsJson = await optionsResponse.json();
49
+
50
+ if (optionsResponse.ok) {
51
+ const credentialOptions = PublicKeyCredential.parseRequestOptionsFromJSON(optionsJson);
52
+ const credential = await getWebAuthnJSON({ publicKey: credentialOptions });
53
+
54
+ this.credentialHiddenInputTarget.value = JSON.stringify(credential);
55
+
56
+ this.element.submit();
57
+ } else {
58
+ alert(optionsJson.errors?.[0] || "Unknown error");
59
+ }
60
+ } catch (error) {
61
+ alert(error.message || error);
62
+ }
63
+ }
64
+ }
@@ -0,0 +1,12 @@
1
+ class WebauthnCredential < ApplicationRecord
2
+ belongs_to :user
3
+
4
+ validates :external_id, :public_key, :nickname, :sign_count, presence: true
5
+ validates :external_id, uniqueness: true
6
+ validates :sign_count,
7
+ numericality: { only_integer: true, greater_than_or_equal_to: 0, less_than_or_equal_to: 2**32 - 1 }
8
+
9
+ enum :authentication_factor, { first_factor: 0, second_factor: 1 }
10
+
11
+ scope :passkey, -> { first_factor }
12
+ end
@@ -0,0 +1,8 @@
1
+ WebAuthn.configure do |config|
2
+ # This value needs to match `window.location.origin` evaluated by
3
+ # the User Agent during registration and authentication ceremonies.
4
+ # config.allowed_origins = [ "https://auth.example.com" ]
5
+
6
+ # Relying Party name for display purposes
7
+ # config.rp_name = "Example Inc."
8
+ end
@@ -0,0 +1,182 @@
1
+ require "rails/generators/base"
2
+ require "rails/generators/active_record/migration"
3
+ if Rails.version >= "8.1"
4
+ require "rails/generators/bundle_helper"
5
+ else
6
+ require "generators/webauthn_authentication/bundle_helper"
7
+ end
8
+
9
+ class WebauthnAuthenticationGenerator < ::Rails::Generators::Base
10
+ include ActiveRecord::Generators::Migration
11
+ include BundleHelper
12
+
13
+ source_root File.expand_path("../templates", __FILE__)
14
+
15
+ desc "Injects webauthn files to your application."
16
+
17
+ class_option :api, type: :boolean,
18
+ desc: "Generate API-only files, with no view templates"
19
+
20
+ class_option :with_rails_authentication, type: :boolean,
21
+ desc: "Run the Ruby on Rails authentication generator"
22
+
23
+ def invoke_rails_authentication
24
+ invoke "authentication" if options.with_rails_authentication?
25
+ end
26
+
27
+ def modify_sessions_controller
28
+ if File.exist?(File.join(destination_root, "app/controllers/sessions_controller.rb"))
29
+ gsub_file "app/controllers/sessions_controller.rb",
30
+ /^ def create.*?^ end/m,
31
+ <<~RUBY.strip_heredoc.indent(2)
32
+ def create
33
+ if user = User.authenticate_by(params.permit(:email_address, :password))
34
+ if user.second_factor_enabled?
35
+ session[:current_authentication] = { user_id: user.id }
36
+ redirect_to new_second_factor_authentication_path
37
+ else
38
+ start_new_session_for user
39
+ redirect_to after_authentication_url
40
+ end
41
+ else
42
+ redirect_to new_session_path, alert: "Try another email address or password."
43
+ end
44
+ end
45
+ RUBY
46
+ else
47
+ raise Thor::Error, "Could not find app/controllers/sessions_controller.rb. Please make sure the Rails Authentication generator was executed, or pass the --with-rails-authentication option."
48
+ end
49
+ end
50
+
51
+ def inject_to_authentication_concern
52
+ inject_into_file "app/controllers/concerns/authentication.rb",
53
+ after: /def terminate_session.*?end\n/m do
54
+ <<-RUBY.strip_heredoc.indent(4)
55
+
56
+ def require_no_authentication
57
+ if Current.user
58
+ redirect_to root_path
59
+ end
60
+ end
61
+ RUBY
62
+ end
63
+ end
64
+
65
+ def copy_controllers_and_concerns
66
+ template "app/controllers/passkeys_controller.rb"
67
+ template "app/controllers/webauthn_sessions_controller.rb"
68
+ template "app/controllers/second_factor_authentications_controller.rb"
69
+ template "app/controllers/second_factor_webauthn_credentials_controller.rb"
70
+ end
71
+
72
+ hook_for :template_engine do |template_engine|
73
+ invoke template_engine unless options.api?
74
+ end
75
+
76
+ def copy_stimulus_controllers
77
+ if using_importmap? || using_bun? || has_package_json?
78
+ template "app/javascript/controllers/webauthn_credentials_controller.js"
79
+
80
+ if using_bun? || has_package_json?
81
+ run "bin/rails stimulus:manifest:update"
82
+ end
83
+ else
84
+ puts "You must either be running with node (package.json) or importmap-rails (config/importmap.rb) to use this gem."
85
+ end
86
+ end
87
+
88
+ def inject_js_packages
89
+ if using_importmap?
90
+ say %(Appending: pin "@github/webauthn-json/browser-ponyfill", to: "https://ga.jspm.io/npm:@github/webauthn-json@2.1.1/dist/esm/webauthn-json.browser-ponyfill.js")
91
+ append_to_file "config/importmap.rb", %(pin "@github/webauthn-json/browser-ponyfill", to: "https://ga.jspm.io/npm:@github/webauthn-json@2.1.1/dist/esm/webauthn-json.browser-ponyfill.js"\n)
92
+ elsif using_bun?
93
+ say "Adding webauthn-json to your package manager"
94
+ run "bun add @github/webauthn-json/browser-ponyfill"
95
+ elsif has_package_json?
96
+ say "Adding webauthn-json to your package manager"
97
+ run "yarn add @github/webauthn-json/browser-ponyfill"
98
+ else
99
+ puts "You must either be running with node (package.json) or importmap-rails (config/importmap.rb) to use this gem."
100
+ end
101
+ end
102
+
103
+ def inject_webauthn_dependency
104
+ unless File.read(File.expand_path("Gemfile", destination_root)).include?('gem "webauthn"')
105
+ bundle_command("add webauthn", {}, quiet: true)
106
+ end
107
+ end
108
+
109
+ def copy_initializer_file
110
+ template "config/initializers/webauthn.rb"
111
+ end
112
+
113
+ def inject_webauthn_content
114
+ generate "migration", "AddWebauthnToUsers", "webauthn_id:string"
115
+ inject_webauthn_content_to_user_model
116
+
117
+ inject_into_file "config/routes.rb", after: "Rails.application.routes.draw do\n" do
118
+ <<-RUBY.strip_heredoc.indent(2)
119
+ resource :webauthn_session, only: [ :create, :destroy ] do
120
+ post :get_options, on: :collection
121
+ end
122
+
123
+ resources :passkeys, only: [ :new, :create, :destroy ] do
124
+ post :create_options, on: :collection
125
+ end
126
+
127
+ resources :second_factor_webauthn_credentials, only: [ :new, :create, :destroy ] do
128
+ post :create_options, on: :collection
129
+ end
130
+
131
+ resource :second_factor_authentication, only: [ :new, :create ] do
132
+ post :get_options, on: :collection
133
+ end
134
+ RUBY
135
+ end
136
+
137
+ template "app/models/webauthn_credential.rb"
138
+ generate "migration", "CreateWebauthnCredentials", "user:references! external_id:string:uniq public_key:string nickname:string sign_count:integer{8} authentication_factor:integer{1}!"
139
+ end
140
+
141
+ hook_for :test_framework
142
+
143
+ def final_message
144
+ say ""
145
+ say "Almost done! Now edit `config/initializers/webauthn.rb` and set the `allowed_origins` and `rp_name` for your app.", :yellow
146
+ end
147
+
148
+ private
149
+
150
+ def using_bun?
151
+ File.exist?(File.join(destination_root, "bun.config.js"))
152
+ end
153
+
154
+ def using_importmap?
155
+ File.exist?(File.join(destination_root, "config/importmap.rb"))
156
+ end
157
+
158
+ def has_package_json?
159
+ File.exist?(File.join(destination_root, "package.json"))
160
+ end
161
+
162
+ def inject_webauthn_content_to_user_model
163
+ inject_into_file "app/models/user.rb", after: "normalizes :email_address, with: ->(e) { e.strip.downcase }\n" do
164
+ <<-RUBY.strip_heredoc.indent(2)
165
+
166
+ has_many :webauthn_credentials, dependent: :destroy
167
+ with_options class_name: "WebauthnCredential" do
168
+ has_many :second_factor_webauthn_credentials, -> { second_factor }
169
+ has_many :passkeys, -> { passkey }
170
+ end
171
+
172
+ after_initialize do
173
+ self.webauthn_id ||= WebAuthn.generate_user_id
174
+ end
175
+
176
+ def second_factor_enabled?
177
+ webauthn_credentials.any?
178
+ end
179
+ RUBY
180
+ end
181
+ end
182
+ end
@@ -0,0 +1,4 @@
1
+ # desc "Explaining what the task does"
2
+ # task :webauthn_rails do
3
+ # # Task goes here
4
+ # end
@@ -1,7 +1,5 @@
1
- # frozen_string_literal: true
2
-
3
1
  module Webauthn
4
2
  module Rails
5
- VERSION = "0.0.1"
3
+ VERSION = "0.1.0"
6
4
  end
7
5
  end
@@ -1,10 +1,6 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative "rails/version"
1
+ require "webauthn/rails/version"
4
2
 
5
3
  module Webauthn
6
4
  module Rails
7
- class Error < StandardError; end
8
- # Your code goes here...
9
5
  end
10
6
  end