webauthn-rails 0.0.1 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/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 +103 -0
- data/lib/generators/test_unit/webauthn_authentication/templates/test/controllers/second_factor_authentications_controller_test.rb +131 -0
- data/lib/generators/test_unit/webauthn_authentication/templates/test/controllers/second_factor_webauthn_credentials_controller_test.rb +103 -0
- data/lib/generators/test_unit/webauthn_authentication/templates/test/controllers/webauthn_sessions_controller_test.rb +123 -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 +25 -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 +184 -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 +55 -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,103 @@
|
|
1
|
+
require "test_helper"
|
2
|
+
require "webauthn/fake_client"
|
3
|
+
|
4
|
+
class SecondFactorWebauthnCredentialsControllerTest < ActionDispatch::IntegrationTest
|
5
|
+
setup do
|
6
|
+
@user = users(:one)
|
7
|
+
@client = WebAuthn::FakeClient.new(WebAuthn.configuration.allowed_origins.first)
|
8
|
+
end
|
9
|
+
|
10
|
+
test "create_options" do
|
11
|
+
sign_in_as @user
|
12
|
+
post create_options_second_factor_webauthn_credentials_url
|
13
|
+
|
14
|
+
assert_response :success
|
15
|
+
body = JSON.parse(response.body)
|
16
|
+
assert body["challenge"].present?
|
17
|
+
assert body["authenticatorSelection"]["residentKey"] == "discouraged"
|
18
|
+
assert body["authenticatorSelection"]["userVerification"] == "discouraged"
|
19
|
+
|
20
|
+
assert_equal session[:current_registration][:challenge], body["challenge"]
|
21
|
+
end
|
22
|
+
|
23
|
+
test "create_options unauthenticated" do
|
24
|
+
post create_options_second_factor_webauthn_credentials_url
|
25
|
+
|
26
|
+
assert_response :redirect
|
27
|
+
assert_redirected_to new_session_url
|
28
|
+
end
|
29
|
+
|
30
|
+
test "create" do
|
31
|
+
sign_in_as @user
|
32
|
+
|
33
|
+
post create_options_second_factor_webauthn_credentials_url
|
34
|
+
challenge = session[:current_registration][:challenge]
|
35
|
+
|
36
|
+
public_key_credential = @client.create(
|
37
|
+
challenge: challenge,
|
38
|
+
user_verified: false,
|
39
|
+
)
|
40
|
+
|
41
|
+
assert_difference("WebauthnCredential.second_factor.count", 1) do
|
42
|
+
post second_factor_webauthn_credentials_url, params: {
|
43
|
+
credential: {
|
44
|
+
nickname: "My Security Key",
|
45
|
+
public_key_credential: public_key_credential.to_json
|
46
|
+
}
|
47
|
+
}
|
48
|
+
end
|
49
|
+
|
50
|
+
assert_redirected_to root_path
|
51
|
+
assert_match (/Security Key registered successfully/), flash[:notice]
|
52
|
+
assert_nil session[:current_registration]
|
53
|
+
end
|
54
|
+
|
55
|
+
test "create with WebAuthn error" do
|
56
|
+
sign_in_as @user
|
57
|
+
|
58
|
+
post create_options_second_factor_webauthn_credentials_url
|
59
|
+
|
60
|
+
public_key_credential = @client.create(
|
61
|
+
user_verified: false,
|
62
|
+
)
|
63
|
+
|
64
|
+
assert_no_difference("WebauthnCredential.count") do
|
65
|
+
post second_factor_webauthn_credentials_url, params: {
|
66
|
+
credential: {
|
67
|
+
nickname: "My Security Key",
|
68
|
+
public_key_credential: public_key_credential.to_json
|
69
|
+
}
|
70
|
+
}
|
71
|
+
end
|
72
|
+
|
73
|
+
assert_redirected_to new_second_factor_webauthn_credential_path
|
74
|
+
assert_match (/Verification failed/), flash[:alert]
|
75
|
+
assert_nil session[:current_registration]
|
76
|
+
end
|
77
|
+
|
78
|
+
test "create unauthenticated" do
|
79
|
+
post second_factor_webauthn_credentials_url
|
80
|
+
|
81
|
+
assert_response :redirect
|
82
|
+
assert_redirected_to new_session_url
|
83
|
+
end
|
84
|
+
|
85
|
+
test "destroy" do
|
86
|
+
credential = WebauthnCredential.second_factor.create!(
|
87
|
+
user: @user,
|
88
|
+
nickname: "My Security Key",
|
89
|
+
external_id: "external_id",
|
90
|
+
public_key: "public_key",
|
91
|
+
sign_count: 0
|
92
|
+
)
|
93
|
+
|
94
|
+
sign_in_as @user
|
95
|
+
|
96
|
+
assert_difference("WebauthnCredential.second_factor.count", -1) do
|
97
|
+
delete second_factor_webauthn_credential_url(credential)
|
98
|
+
end
|
99
|
+
|
100
|
+
assert_redirected_to root_path
|
101
|
+
assert_match (/Security Key deleted successfully/), flash[:notice]
|
102
|
+
end
|
103
|
+
end
|
@@ -0,0 +1,123 @@
|
|
1
|
+
require "test_helper"
|
2
|
+
require "webauthn/fake_client"
|
3
|
+
|
4
|
+
class WebauthnSessionsControllerTest < ActionDispatch::IntegrationTest
|
5
|
+
setup do
|
6
|
+
@user = users(:one)
|
7
|
+
@client = WebAuthn::FakeClient.new(WebAuthn.configuration.allowed_origins.first)
|
8
|
+
|
9
|
+
creation_options = WebAuthn::Credential.options_for_create(
|
10
|
+
user: { id: @user.webauthn_id, name: @user.email_address }
|
11
|
+
)
|
12
|
+
create_options = @client.create(challenge: creation_options.challenge)
|
13
|
+
credential = WebAuthn::Credential.from_create(create_options)
|
14
|
+
|
15
|
+
WebauthnCredential.passkey.create!(
|
16
|
+
nickname: "My Passkey",
|
17
|
+
user: @user,
|
18
|
+
external_id: credential.id,
|
19
|
+
public_key: credential.public_key,
|
20
|
+
sign_count: 0,
|
21
|
+
)
|
22
|
+
end
|
23
|
+
|
24
|
+
test "get_options" do
|
25
|
+
post get_options_webauthn_session_url
|
26
|
+
|
27
|
+
assert_response :success
|
28
|
+
body = JSON.parse(response.body)
|
29
|
+
assert body["challenge"].present?
|
30
|
+
assert body["userVerification"] == "required"
|
31
|
+
|
32
|
+
assert_equal session[:current_authentication][:challenge], body["challenge"]
|
33
|
+
end
|
34
|
+
|
35
|
+
test "create" do
|
36
|
+
post get_options_webauthn_session_url
|
37
|
+
challenge = session[:current_authentication][:challenge]
|
38
|
+
|
39
|
+
public_key_credential = @client.get(challenge: challenge, user_verified: true)
|
40
|
+
|
41
|
+
post webauthn_session_url, params: {
|
42
|
+
session: {
|
43
|
+
public_key_credential: public_key_credential.to_json
|
44
|
+
}
|
45
|
+
}
|
46
|
+
|
47
|
+
assert_redirected_to root_path
|
48
|
+
assert_nil session[:current_authentication]
|
49
|
+
end
|
50
|
+
|
51
|
+
test "create with WebAuthn error" do
|
52
|
+
post get_options_webauthn_session_url
|
53
|
+
challenge = session[:current_authentication][:challenge]
|
54
|
+
|
55
|
+
public_key_credential = @client.get(challenge: challenge, user_verified: false)
|
56
|
+
|
57
|
+
post webauthn_session_url, params: {
|
58
|
+
session: {
|
59
|
+
public_key_credential: public_key_credential.to_json
|
60
|
+
}
|
61
|
+
}
|
62
|
+
|
63
|
+
assert_redirected_to new_session_path
|
64
|
+
assert_match (/Verification failed/), flash[:alert]
|
65
|
+
assert_nil session[:current_authentication]
|
66
|
+
end
|
67
|
+
|
68
|
+
test "create with unrecognized credential" do
|
69
|
+
post get_options_webauthn_session_url
|
70
|
+
challenge = session[:current_authentication][:challenge]
|
71
|
+
|
72
|
+
public_key_credential = @client.get(challenge: challenge, user_verified: true)
|
73
|
+
public_key_credential["id"] = "invalid-id"
|
74
|
+
|
75
|
+
post webauthn_session_url, params: {
|
76
|
+
session: {
|
77
|
+
public_key_credential: public_key_credential.to_json
|
78
|
+
}
|
79
|
+
}
|
80
|
+
|
81
|
+
assert_redirected_to new_session_path
|
82
|
+
assert_equal "Credential not recognized", flash[:alert]
|
83
|
+
assert_nil session[:current_authentication]
|
84
|
+
end
|
85
|
+
|
86
|
+
test "create with a second factor credential" do
|
87
|
+
client = WebAuthn::FakeClient.new(WebAuthn.configuration.allowed_origins.first)
|
88
|
+
|
89
|
+
creation_options = WebAuthn::Credential.options_for_create(
|
90
|
+
user: { id: @user.webauthn_id, name: @user.email_address }
|
91
|
+
)
|
92
|
+
create_options = client.create(challenge: creation_options.challenge)
|
93
|
+
credential = WebAuthn::Credential.from_create(create_options)
|
94
|
+
|
95
|
+
WebauthnCredential.second_factor.create!(
|
96
|
+
nickname: "Second Factor Key",
|
97
|
+
user: @user,
|
98
|
+
external_id: credential.id,
|
99
|
+
public_key: credential.public_key,
|
100
|
+
sign_count: 0,
|
101
|
+
)
|
102
|
+
|
103
|
+
post get_options_webauthn_session_url
|
104
|
+
challenge = session[:current_authentication][:challenge]
|
105
|
+
|
106
|
+
public_key_credential = client.get(challenge: challenge, user_verified: true)
|
107
|
+
|
108
|
+
post webauthn_session_url, params: {
|
109
|
+
session: {
|
110
|
+
public_key_credential: public_key_credential.to_json
|
111
|
+
}
|
112
|
+
}
|
113
|
+
|
114
|
+
assert_redirected_to new_session_path
|
115
|
+
assert_equal "Credential not recognized", flash[:alert]
|
116
|
+
assert_nil session[:current_authentication]
|
117
|
+
end
|
118
|
+
|
119
|
+
test "destroy" do
|
120
|
+
delete webauthn_session_url
|
121
|
+
assert_redirected_to new_session_path
|
122
|
+
end
|
123
|
+
end
|
@@ -0,0 +1,76 @@
|
|
1
|
+
require "application_system_test_case"
|
2
|
+
require_relative "../test_helpers/virtual_authenticator_test_helper"
|
3
|
+
|
4
|
+
class ManageWebauthnCredentialsTest < ApplicationSystemTestCase
|
5
|
+
include VirtualAuthenticatorTestHelper
|
6
|
+
|
7
|
+
def setup
|
8
|
+
user = User.create!(email_address: "alice@example.com", password: "S3cr3tP@ssw0rd!")
|
9
|
+
sign_in_as(user)
|
10
|
+
@authenticator = add_virtual_authenticator
|
11
|
+
end
|
12
|
+
|
13
|
+
def teardown
|
14
|
+
@authenticator.remove!
|
15
|
+
end
|
16
|
+
|
17
|
+
test "add credentials and sign in" do
|
18
|
+
visit root_path
|
19
|
+
|
20
|
+
click_on "Add Passkey"
|
21
|
+
|
22
|
+
fill_in("Security Key nickname", with: "Touch ID")
|
23
|
+
click_on "Add Security Key"
|
24
|
+
|
25
|
+
assert_current_path "/"
|
26
|
+
assert_selector "div", text: "Security Key registered successfully"
|
27
|
+
assert_selector "span", text: "Touch ID"
|
28
|
+
|
29
|
+
click_on "Sign out"
|
30
|
+
assert_selector("input[type=submit][value='Sign in']")
|
31
|
+
|
32
|
+
click_on "Sign In with Passkey"
|
33
|
+
|
34
|
+
assert_current_path "/"
|
35
|
+
assert_selector "h3", text: "Your Passkeys"
|
36
|
+
end
|
37
|
+
|
38
|
+
test "sign in with 2FA WebAuthn credential" do
|
39
|
+
visit root_path
|
40
|
+
|
41
|
+
click_on "Add Second Factor Key"
|
42
|
+
|
43
|
+
fill_in("Security Key nickname", with: "Touch ID")
|
44
|
+
click_on "Add Security Key"
|
45
|
+
|
46
|
+
assert_current_path "/"
|
47
|
+
assert_selector "div", text: "Security Key registered successfully"
|
48
|
+
assert_selector "span", text: "Touch ID"
|
49
|
+
|
50
|
+
click_on "Sign out"
|
51
|
+
assert_selector("input[type=submit][value='Sign in']")
|
52
|
+
|
53
|
+
fill_in "email_address", with: "alice@example.com"
|
54
|
+
fill_in "password", with: "S3cr3tP@ssw0rd!"
|
55
|
+
click_on "Sign in"
|
56
|
+
|
57
|
+
assert_selector "h3", text: "Two-factor authentication"
|
58
|
+
click_on "Use Security Key"
|
59
|
+
|
60
|
+
assert_current_path "/"
|
61
|
+
assert_selector "h3", text: "Your Passkeys"
|
62
|
+
end
|
63
|
+
|
64
|
+
private
|
65
|
+
|
66
|
+
def sign_in_as(user)
|
67
|
+
visit new_session_path
|
68
|
+
|
69
|
+
fill_in "email_address", with: user.email_address
|
70
|
+
fill_in "password", with: user.password
|
71
|
+
|
72
|
+
click_on "Sign in"
|
73
|
+
|
74
|
+
assert_selector "h3", text: "Your Passkeys"
|
75
|
+
end
|
76
|
+
end
|
@@ -0,0 +1,9 @@
|
|
1
|
+
module VirtualAuthenticatorTestHelper
|
2
|
+
def add_virtual_authenticator
|
3
|
+
options = ::Selenium::WebDriver::VirtualAuthenticatorOptions.new
|
4
|
+
options.user_verification = true
|
5
|
+
options.user_verified = true
|
6
|
+
options.resident_key = true
|
7
|
+
page.driver.browser.add_virtual_authenticator(options)
|
8
|
+
end
|
9
|
+
end
|
@@ -0,0 +1,25 @@
|
|
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
|
+
template "test/controllers/second_factor_authentications_controller_test.rb"
|
13
|
+
template "test/controllers/second_factor_webauthn_credentials_controller_test.rb"
|
14
|
+
end
|
15
|
+
|
16
|
+
def create_system_test_files
|
17
|
+
template "test/system/manage_webauthn_credentials_test.rb"
|
18
|
+
end
|
19
|
+
|
20
|
+
def create_test_helper_files
|
21
|
+
template "test/test_helpers/virtual_authenticator_test_helper.rb"
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
25
|
+
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
|
+
end
|
39
|
+
ensure
|
40
|
+
session.delete(:current_authentication)
|
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.dig(:current_authentication, :user_id) || session.dig(: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
|
+
end
|
44
|
+
ensure
|
45
|
+
session.delete(:current_registration)
|
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
|