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.
- checksums.yaml +4 -4
- data/MIT-LICENSE +20 -0
- data/README.md +167 -13
- data/Rakefile +12 -4
- data/lib/generators/erb/webauthn_authentication/templates/app/views/passkeys/new.html.erb.tt +18 -0
- data/lib/generators/erb/webauthn_authentication/templates/app/views/second_factor_authentications/new.html.erb.tt +18 -0
- data/lib/generators/erb/webauthn_authentication/templates/app/views/second_factor_webauthn_credentials/new.html.erb.tt +18 -0
- data/lib/generators/erb/webauthn_authentication/webauthn_authentication_generator.rb +35 -0
- data/lib/generators/test_unit/webauthn_authentication/templates/test/controllers/passkeys_controller_test.rb +111 -0
- data/lib/generators/test_unit/webauthn_authentication/templates/test/controllers/webauthn_sessions_controller_test.rb +125 -0
- data/lib/generators/test_unit/webauthn_authentication/templates/test/system/manage_webauthn_credentials_test.rb +76 -0
- data/lib/generators/test_unit/webauthn_authentication/templates/test/test_helpers/virtual_authenticator_test_helper.rb +9 -0
- data/lib/generators/test_unit/webauthn_authentication/webauthn_authentication_generator.rb +23 -0
- data/lib/generators/webauthn_authentication/bundle_helper.rb +30 -0
- data/lib/generators/webauthn_authentication/templates/app/controllers/passkeys_controller.rb +61 -0
- data/lib/generators/webauthn_authentication/templates/app/controllers/second_factor_authentications_controller.rb +62 -0
- data/lib/generators/webauthn_authentication/templates/app/controllers/second_factor_webauthn_credentials_controller.rb +59 -0
- data/lib/generators/webauthn_authentication/templates/app/controllers/webauthn_sessions_controller.rb +50 -0
- data/lib/generators/webauthn_authentication/templates/app/javascript/controllers/webauthn_credentials_controller.js +64 -0
- data/lib/generators/webauthn_authentication/templates/app/models/webauthn_credential.rb +12 -0
- data/lib/generators/webauthn_authentication/templates/config/initializers/webauthn.rb +8 -0
- data/lib/generators/webauthn_authentication/webauthn_authentication_generator.rb +182 -0
- data/lib/tasks/webauthn/rails_tasks.rake +4 -0
- data/lib/webauthn/rails/version.rb +1 -3
- data/lib/webauthn/rails.rb +1 -5
- metadata +53 -18
- data/.rspec +0 -3
- data/CHANGELOG.md +0 -5
- data/LICENSE.txt +0 -21
- 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
|