maquina-generators 0.8.0 → 0.8.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/lib/generators/maquina/passkeys/passkeys_generator.rb +3 -2
- data/lib/generators/maquina/passkeys/templates/app/controllers/passkeys_controller.rb +2 -2
- data/lib/generators/maquina/passkeys/templates/app/views/passkeys/index.html.erb +1 -1
- data/lib/maquina_generators/version.rb +1 -1
- data/test/generators/maquina/passkeys_generator_test.rb +14 -0
- data/test/tmp/Gemfile +2 -1
- data/test/tmp/app/controllers/concerns/authentication.rb +0 -61
- data/test/tmp/app/controllers/concerns/passkey_challenge.rb +42 -0
- data/test/tmp/app/controllers/concerns/passkey_reauthentication.rb +26 -0
- data/test/tmp/app/controllers/passkey_challenges_controller.rb +24 -0
- data/test/tmp/app/controllers/passkeys_controller.rb +68 -0
- data/test/tmp/app/controllers/session/passkey_challenges_controller.rb +18 -0
- data/test/tmp/app/controllers/session/passkeys_controller.rb +55 -0
- data/test/tmp/app/javascript/controllers/passkey_registration_controller.js +109 -0
- data/test/tmp/app/javascript/controllers/passkey_sign_in_controller.js +113 -0
- data/test/tmp/app/models/email_verification.rb +0 -56
- data/test/tmp/app/models/passkey.rb +36 -0
- data/test/tmp/app/models/user.rb +10 -0
- data/test/tmp/app/views/passkeys/index.html.erb +77 -0
- data/test/tmp/app/views/sessions/_passkey.html.erb +23 -0
- data/test/tmp/app/views/sessions/new.html.erb +37 -0
- data/test/tmp/config/initializers/rack_attack.rb +9 -0
- data/test/tmp/config/initializers/webauthn.rb +23 -0
- data/test/tmp/config/locales/passkeys.en.yml +46 -0
- data/test/tmp/config/locales/passkeys.es.yml +46 -0
- data/test/tmp/config/routes.rb +6 -9
- data/test/tmp/db/migrate/20261014213621_add_webauthn_id_to_users.rb +6 -0
- data/test/tmp/db/migrate/20261014213622_create_passkeys.rb +20 -0
- data/test/tmp/test/integration/passkeys_test.rb +99 -0
- data/test/tmp/test/test_helper.rb +4 -0
- data/test/tmp/test/test_helpers/webauthn_test_helper.rb +38 -0
- metadata +22 -21
- data/test/tmp/app/controllers/application_controller.rb +0 -3
- data/test/tmp/app/controllers/registration/verification_resends_controller.rb +0 -38
- data/test/tmp/app/controllers/registration/verifications_controller.rb +0 -64
- data/test/tmp/app/controllers/registrations_controller.rb +0 -63
- data/test/tmp/app/controllers/session/verification_resends_controller.rb +0 -44
- data/test/tmp/app/controllers/session/verifications_controller.rb +0 -64
- data/test/tmp/app/controllers/sessions_controller.rb +0 -56
- data/test/tmp/app/helpers/authentication_helper.rb +0 -20
- data/test/tmp/app/jobs/authentication_cleanup_job.rb +0 -13
- data/test/tmp/app/mailers/verification_mailer.rb +0 -15
- data/test/tmp/app/models/account.rb +0 -5
- data/test/tmp/app/models/current.rb +0 -5
- data/test/tmp/app/models/session.rb +0 -18
- data/test/tmp/config/locales/clave.en.yml +0 -102
- data/test/tmp/config/locales/clave.es.yml +0 -102
- data/test/tmp/db/migrate/20261014212613_create_accounts.rb +0 -9
- data/test/tmp/db/migrate/20261014212614_create_users.rb +0 -17
- data/test/tmp/db/migrate/20261014212615_create_sessions.rb +0 -12
- data/test/tmp/db/migrate/20261014212616_create_email_verifications.rb +0 -17
- data/test/tmp/test/test_helpers/session_test_helper.rb +0 -19
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7e7ab2221409345c84231d6908e18f5f14b265d32a10229f288f23d4c5a03006
|
|
4
|
+
data.tar.gz: b4966e9de4099d0664da18fd6ba2818b560770cdb2dab121a9f06265e38b4769
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: f7b0d031778c2aa4b8cc03079e08f049a79d499500dfe0bb8b44b5e94e7743afdfc0ea6d0625d97b0128c937628980c9eb5317eef5bfe9ea62d68cd25f3e0487
|
|
7
|
+
data.tar.gz: 2ccfde4751e49534fb7c36149c623ea8fd93849a8e26e860fae0566556f2832173f8f488b708dca6e23f8890e8a6b4243571e61c6811723a325c1535f2a5dde5
|
|
@@ -248,9 +248,10 @@ module Maquina
|
|
|
248
248
|
<<~RUBY
|
|
249
249
|
|
|
250
250
|
# Passkey sign-in (maquina:passkeys): the challenge and the assertion,
|
|
251
|
-
# counted like the email-code login above.
|
|
251
|
+
# counted like the email-code login above. Keyed on the address Rails
|
|
252
|
+
# resolves through trusted proxies, not the proxy hop in req.ip.
|
|
252
253
|
Rack::Attack.throttle("passkey/ip", limit: #{THROTTLE_LIMIT}, period: #{THROTTLE_PERIOD}) do |req|
|
|
253
|
-
req.ip if req.path.start_with?("/session/passkey") && req.post?
|
|
254
|
+
(req.env["action_dispatch.remote_ip"]&.to_s || req.ip) if req.path.start_with?("/session/passkey") && req.post?
|
|
254
255
|
end
|
|
255
256
|
RUBY
|
|
256
257
|
end
|
|
@@ -43,9 +43,9 @@ class PasskeysController < ApplicationController
|
|
|
43
43
|
# PATCH /passkeys/:id
|
|
44
44
|
def update
|
|
45
45
|
if @passkey.update(params.expect(passkey: [:name]))
|
|
46
|
-
redirect_to passkeys_path, notice: t("flash.passkeys.updated")
|
|
46
|
+
redirect_to passkeys_path, notice: t("flash.passkeys.updated"), status: :see_other
|
|
47
47
|
else
|
|
48
|
-
redirect_to passkeys_path, alert: @passkey.errors.full_messages.to_sentence
|
|
48
|
+
redirect_to passkeys_path, alert: @passkey.errors.full_messages.to_sentence, status: :see_other
|
|
49
49
|
end
|
|
50
50
|
end
|
|
51
51
|
|
|
@@ -61,7 +61,7 @@
|
|
|
61
61
|
|
|
62
62
|
<%= button_to t(".remove"), passkey_path(passkey), method: :delete,
|
|
63
63
|
form: { data: { turbo_confirm: t(".remove_confirm") } },
|
|
64
|
-
data: { component: "button", variant: "
|
|
64
|
+
data: { component: "button", variant: "ghost" },
|
|
65
65
|
class: "px-3 py-2 text-sm font-medium text-destructive hover:bg-destructive/10 rounded-lg cursor-pointer" %>
|
|
66
66
|
</li>
|
|
67
67
|
<% end %>
|
|
@@ -124,6 +124,19 @@ class Maquina::Generators::PasskeysGeneratorTest < Rails::Generators::TestCase
|
|
|
124
124
|
end
|
|
125
125
|
end
|
|
126
126
|
|
|
127
|
+
test "renaming and removing redirect with 303 and the remove button stays readable" do
|
|
128
|
+
run_generator
|
|
129
|
+
|
|
130
|
+
assert_file "app/controllers/passkeys_controller.rb" do |content|
|
|
131
|
+
assert_equal 3, content.scan(/redirect_to passkeys_path, .*status: :see_other$/).size
|
|
132
|
+
assert_no_match(/redirect_to(?!.*see_other)/, content)
|
|
133
|
+
end
|
|
134
|
+
assert_file "app/views/passkeys/index.html.erb" do |content|
|
|
135
|
+
assert_no_match(/variant: "destructive"/, content)
|
|
136
|
+
assert_match(/variant: "ghost" },\n\s+class: "[^"]*text-destructive hover:bg-destructive\/10/, content)
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
127
140
|
test "adds the passkey button and autofill to the sign-in page" do
|
|
128
141
|
run_generator
|
|
129
142
|
|
|
@@ -197,6 +210,7 @@ class Maquina::Generators::PasskeysGeneratorTest < Rails::Generators::TestCase
|
|
|
197
210
|
run_generator
|
|
198
211
|
|
|
199
212
|
assert_file "config/initializers/rack_attack.rb", /Rack::Attack\.throttle\("passkey\/ip", limit: 20, period: 1\.minute\)/
|
|
213
|
+
assert_file "config/initializers/rack_attack.rb", /\(req\.env\["action_dispatch\.remote_ip"\]&\.to_s \|\| req\.ip\) if req\.path\.start_with\?\("\/session\/passkey"\)/
|
|
200
214
|
end
|
|
201
215
|
|
|
202
216
|
test "running twice changes nothing" do
|
data/test/tmp/Gemfile
CHANGED
|
@@ -1,63 +1,2 @@
|
|
|
1
1
|
module Authentication
|
|
2
|
-
extend ActiveSupport::Concern
|
|
3
|
-
|
|
4
|
-
included do
|
|
5
|
-
before_action :require_authentication
|
|
6
|
-
helper_method :authenticated?, :session_expires_at
|
|
7
|
-
end
|
|
8
|
-
|
|
9
|
-
class_methods do
|
|
10
|
-
def allow_unauthenticated_access(**options)
|
|
11
|
-
skip_before_action :require_authentication, **options
|
|
12
|
-
end
|
|
13
|
-
end
|
|
14
|
-
|
|
15
|
-
private
|
|
16
|
-
|
|
17
|
-
def authenticated?
|
|
18
|
-
resume_session
|
|
19
|
-
end
|
|
20
|
-
|
|
21
|
-
def require_authentication
|
|
22
|
-
resume_session || request_authentication
|
|
23
|
-
end
|
|
24
|
-
|
|
25
|
-
def resume_session
|
|
26
|
-
Current.session ||= find_session_by_cookie
|
|
27
|
-
end
|
|
28
|
-
|
|
29
|
-
def find_session_by_cookie
|
|
30
|
-
return nil unless cookies.signed[:session_id]
|
|
31
|
-
|
|
32
|
-
Session.active.find_by(id: cookies.signed[:session_id])
|
|
33
|
-
end
|
|
34
|
-
|
|
35
|
-
def request_authentication
|
|
36
|
-
session[:return_to_after_authenticating] = request.url
|
|
37
|
-
redirect_to new_session_path
|
|
38
|
-
end
|
|
39
|
-
|
|
40
|
-
def after_authentication_url
|
|
41
|
-
session.delete(:return_to_after_authenticating) || root_url
|
|
42
|
-
end
|
|
43
|
-
|
|
44
|
-
def start_new_session_for(user)
|
|
45
|
-
user.sessions.create!(
|
|
46
|
-
user_agent: request.user_agent,
|
|
47
|
-
ip_address: request.remote_ip,
|
|
48
|
-
expires_at: 30.days.from_now
|
|
49
|
-
).tap do |session|
|
|
50
|
-
Current.session = session
|
|
51
|
-
cookies.signed.permanent[:session_id] = {value: session.id, httponly: true, same_site: :lax}
|
|
52
|
-
end
|
|
53
|
-
end
|
|
54
|
-
|
|
55
|
-
def terminate_session
|
|
56
|
-
Current.session.destroy
|
|
57
|
-
cookies.delete(:session_id)
|
|
58
|
-
end
|
|
59
|
-
|
|
60
|
-
def session_expires_at
|
|
61
|
-
Current.session&.expires_at
|
|
62
|
-
end
|
|
63
2
|
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# One WebAuthn challenge per ceremony, kept in the session (generated by
|
|
2
|
+
# maquina:passkeys).
|
|
3
|
+
#
|
|
4
|
+
# The browser signs the challenge, so a stored one that could be used twice
|
|
5
|
+
# would let a captured response be replayed. take_passkey_challenge removes it
|
|
6
|
+
# before anything is verified, and a challenge older than CHALLENGE_TTL is
|
|
7
|
+
# refused. Registration and sign-in keep theirs under separate keys, so one
|
|
8
|
+
# ceremony never consumes the other's.
|
|
9
|
+
module PasskeyChallenge
|
|
10
|
+
extend ActiveSupport::Concern
|
|
11
|
+
|
|
12
|
+
CHALLENGE_TTL = 5.minutes
|
|
13
|
+
|
|
14
|
+
private
|
|
15
|
+
|
|
16
|
+
def store_passkey_challenge(purpose, challenge)
|
|
17
|
+
session[passkey_challenge_key(purpose)] = {
|
|
18
|
+
"challenge" => challenge,
|
|
19
|
+
"expires_at" => CHALLENGE_TTL.from_now.to_i
|
|
20
|
+
}
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def take_passkey_challenge(purpose)
|
|
24
|
+
stored = session.delete(passkey_challenge_key(purpose))
|
|
25
|
+
stored["challenge"] if stored && stored["expires_at"].to_i > Time.current.to_i
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def passkey_challenge_key(purpose)
|
|
29
|
+
:"passkey_#{purpose}_challenge"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# The browser's PublicKeyCredential#toJSON(). It is checked by signature, not
|
|
33
|
+
# assigned to a record, so only the shape is permitted here.
|
|
34
|
+
def passkey_credential_params
|
|
35
|
+
params.require(:credential).permit(
|
|
36
|
+
:id, :rawId, :type, :authenticatorAttachment,
|
|
37
|
+
response: [:clientDataJSON, :attestationObject, :authenticatorData, :signature, :userHandle,
|
|
38
|
+
:publicKey, :publicKeyAlgorithm, transports: []],
|
|
39
|
+
clientExtensionResults: {}
|
|
40
|
+
).to_h
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# Adding or removing a passkey asks for a recent sign-in (generated by
|
|
2
|
+
# maquina:passkeys).
|
|
3
|
+
#
|
|
4
|
+
# A passkey added from a stolen session cookie would outlive the cookie, so
|
|
5
|
+
# a session older than REAUTHENTICATE_AFTER is ended and the person signs in
|
|
6
|
+
# again (email code or passkey), coming back to the passkeys page after.
|
|
7
|
+
module PasskeyReauthentication
|
|
8
|
+
extend ActiveSupport::Concern
|
|
9
|
+
|
|
10
|
+
REAUTHENTICATE_AFTER = 30.minutes
|
|
11
|
+
|
|
12
|
+
private
|
|
13
|
+
|
|
14
|
+
def require_recent_sign_in
|
|
15
|
+
return if Current.session.created_at.after?(REAUTHENTICATE_AFTER.ago)
|
|
16
|
+
|
|
17
|
+
terminate_session
|
|
18
|
+
session[:return_to_after_authenticating] = passkeys_url
|
|
19
|
+
flash[:alert] = t("flash.passkeys.reauthenticate")
|
|
20
|
+
|
|
21
|
+
respond_to do |format|
|
|
22
|
+
format.html { redirect_to new_session_path }
|
|
23
|
+
format.json { render json: {redirect_url: new_session_url}, status: :unauthorized }
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
class PasskeyChallengesController < ApplicationController
|
|
2
|
+
include PasskeyChallenge
|
|
3
|
+
include PasskeyReauthentication
|
|
4
|
+
|
|
5
|
+
before_action :require_recent_sign_in
|
|
6
|
+
|
|
7
|
+
# POST /passkey_challenge
|
|
8
|
+
#
|
|
9
|
+
# Options for navigator.credentials.create(). A discoverable credential
|
|
10
|
+
# (resident key) is what makes it a passkey: the device keeps the account,
|
|
11
|
+
# so sign-in needs no email. The user's existing passkeys are excluded so the
|
|
12
|
+
# same authenticator is not registered twice.
|
|
13
|
+
def create
|
|
14
|
+
user = Current.user
|
|
15
|
+
options = WebAuthn::Credential.options_for_create(
|
|
16
|
+
user: {id: user.webauthn_id, name: user.email_address, display_name: user.name.presence || user.email_address},
|
|
17
|
+
exclude: user.passkeys.pluck(:credential_id),
|
|
18
|
+
authenticator_selection: {resident_key: "required", user_verification: "required"}
|
|
19
|
+
)
|
|
20
|
+
store_passkey_challenge(:registration, options.challenge)
|
|
21
|
+
|
|
22
|
+
render json: options
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
class PasskeysController < ApplicationController
|
|
2
|
+
include PasskeyChallenge
|
|
3
|
+
include PasskeyReauthentication
|
|
4
|
+
|
|
5
|
+
before_action :require_recent_sign_in, only: %i[create destroy]
|
|
6
|
+
before_action :set_passkey, only: %i[update destroy]
|
|
7
|
+
|
|
8
|
+
# GET /passkeys
|
|
9
|
+
def index
|
|
10
|
+
@passkeys = Current.user.passkeys.order(:created_at)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# POST /passkeys
|
|
14
|
+
def create
|
|
15
|
+
challenge = take_passkey_challenge(:registration)
|
|
16
|
+
return render_failure(t("flash.passkeys.challenge_expired")) unless challenge
|
|
17
|
+
|
|
18
|
+
credential = WebAuthn::Credential.from_create(passkey_credential_params)
|
|
19
|
+
# Checks the challenge, the origin, the relying party and that the person
|
|
20
|
+
# was verified on the device, not just present.
|
|
21
|
+
credential.verify(challenge, user_verification: true)
|
|
22
|
+
|
|
23
|
+
aaguid = credential.response.authenticator_data.aaguid
|
|
24
|
+
Current.user.passkeys.create!(
|
|
25
|
+
credential_id: credential.id,
|
|
26
|
+
public_key: credential.public_key,
|
|
27
|
+
sign_count: credential.sign_count,
|
|
28
|
+
transports: credential.response.transports,
|
|
29
|
+
aaguid: aaguid,
|
|
30
|
+
backup_eligible: credential.backup_eligible?,
|
|
31
|
+
backed_up: credential.backed_up?,
|
|
32
|
+
name: Passkey.default_name(aaguid)
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
Rails.logger.info "[AUTH] Passkey added for #{Current.user.email_address} from #{request.remote_ip}"
|
|
36
|
+
flash[:notice] = t("flash.passkeys.created")
|
|
37
|
+
render json: {redirect_url: passkeys_url}, status: :created
|
|
38
|
+
rescue WebAuthn::Error, ActiveRecord::RecordInvalid => error
|
|
39
|
+
Rails.logger.info "[AUTH] Passkey registration failed (#{error.class.name.demodulize}) for #{Current.user.email_address}"
|
|
40
|
+
render_failure(t("flash.passkeys.registration_failed"))
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# PATCH /passkeys/:id
|
|
44
|
+
def update
|
|
45
|
+
if @passkey.update(params.expect(passkey: [:name]))
|
|
46
|
+
redirect_to passkeys_path, notice: t("flash.passkeys.updated"), status: :see_other
|
|
47
|
+
else
|
|
48
|
+
redirect_to passkeys_path, alert: @passkey.errors.full_messages.to_sentence, status: :see_other
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# DELETE /passkeys/:id
|
|
53
|
+
def destroy
|
|
54
|
+
@passkey.destroy
|
|
55
|
+
Rails.logger.info "[AUTH] Passkey removed for #{Current.user.email_address} from #{request.remote_ip}"
|
|
56
|
+
redirect_to passkeys_path, notice: t("flash.passkeys.destroyed"), status: :see_other
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
def set_passkey
|
|
62
|
+
@passkey = Current.user.passkeys.find(params[:id])
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def render_failure(message)
|
|
66
|
+
render json: {error: message}, status: :unprocessable_entity
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
class Session::PasskeyChallengesController < ApplicationController
|
|
2
|
+
include PasskeyChallenge
|
|
3
|
+
|
|
4
|
+
allow_unauthenticated_access
|
|
5
|
+
|
|
6
|
+
# POST /session/passkey_challenge
|
|
7
|
+
#
|
|
8
|
+
# Options for navigator.credentials.get(). No allowCredentials: the browser
|
|
9
|
+
# offers every passkey it holds for this site, so nobody types an email and
|
|
10
|
+
# the response says nothing about which accounts exist. It is a POST so a
|
|
11
|
+
# Turbo-cached page never reuses a challenge.
|
|
12
|
+
def create
|
|
13
|
+
options = WebAuthn::Credential.options_for_get(user_verification: "required")
|
|
14
|
+
store_passkey_challenge(:authentication, options.challenge)
|
|
15
|
+
|
|
16
|
+
render json: options
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
class Session::PasskeysController < ApplicationController
|
|
2
|
+
include PasskeyChallenge
|
|
3
|
+
|
|
4
|
+
allow_unauthenticated_access
|
|
5
|
+
rate_limit to: 10, within: 3.minutes, only: :create, with: -> { render json: {error: t("flash.general.rate_limited")}, status: :too_many_requests }
|
|
6
|
+
|
|
7
|
+
# POST /session/passkey
|
|
8
|
+
def create
|
|
9
|
+
challenge = take_passkey_challenge(:authentication)
|
|
10
|
+
return render_failure(t("flash.passkeys.challenge_expired")) unless challenge
|
|
11
|
+
|
|
12
|
+
credential = WebAuthn::Credential.from_get(passkey_credential_params)
|
|
13
|
+
passkey = Passkey.includes(:user).find_by(credential_id: credential.id)
|
|
14
|
+
|
|
15
|
+
# Deleted here but still saved on the device: the browser can be told to
|
|
16
|
+
# forget it (PublicKeyCredential.signalUnknownCredential).
|
|
17
|
+
unless passkey
|
|
18
|
+
Rails.logger.info "[AUTH] Passkey sign-in with an unknown credential from #{request.remote_ip}"
|
|
19
|
+
render json: {error: t("flash.passkeys.unknown"), credential_id: credential.id, rp_id: WebAuthn.configuration.rp_id},
|
|
20
|
+
status: :not_found
|
|
21
|
+
return
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
user = passkey.user
|
|
25
|
+
if user.blocked? || credential.user_handle != user.webauthn_id
|
|
26
|
+
Rails.logger.info "[AUTH] Passkey sign-in refused for #{user.email_address} from #{request.remote_ip}"
|
|
27
|
+
return render_failure(t("flash.passkeys.failed"))
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Checks the challenge, the origin, the signature with the stored public
|
|
31
|
+
# key, user verification (Face ID, fingerprint, device PIN) and that the
|
|
32
|
+
# signature counter moved forward.
|
|
33
|
+
credential.verify(challenge, public_key: passkey.public_key, sign_count: passkey.sign_count, user_verification: true)
|
|
34
|
+
passkey.record_use!(credential.sign_count)
|
|
35
|
+
|
|
36
|
+
Rails.logger.info "[AUTH] Successful passkey login for #{user.email_address} from #{request.remote_ip}"
|
|
37
|
+
start_new_session_for(user)
|
|
38
|
+
flash[:notice] = t("flash.sessions.create.success")
|
|
39
|
+
render json: {redirect_url: after_authentication_url}
|
|
40
|
+
rescue WebAuthn::SignCountVerificationError
|
|
41
|
+
# A valid signature with a counter that went backwards: possibly a cloned
|
|
42
|
+
# authenticator. Synced passkeys always report 0 and never land here.
|
|
43
|
+
Rails.logger.warn "[AUTH] Passkey sign count went backwards for #{passkey.user.email_address} from #{request.remote_ip}"
|
|
44
|
+
render_failure(t("flash.passkeys.failed"))
|
|
45
|
+
rescue WebAuthn::Error => error
|
|
46
|
+
Rails.logger.info "[AUTH] Passkey sign-in failed (#{error.class.name.demodulize}) from #{request.remote_ip}"
|
|
47
|
+
render_failure(t("flash.passkeys.failed"))
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def render_failure(message)
|
|
53
|
+
render json: {error: message}, status: :unprocessable_entity
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
|
|
3
|
+
// Passkey registration and upkeep (maquina:passkeys).
|
|
4
|
+
//
|
|
5
|
+
// The button creates a passkey with the browser's own JSON helpers. On
|
|
6
|
+
// connect the page tells the passkey provider which passkeys this account
|
|
7
|
+
// still has and how it is named, so one removed here disappears from the
|
|
8
|
+
// device's list too. Both signals are skipped where unsupported.
|
|
9
|
+
//
|
|
10
|
+
// The button is rendered visible and only hidden where passkeys are not
|
|
11
|
+
// supported. A Turbo morph puts the server's attributes back without
|
|
12
|
+
// reconnecting this controller, so the view calls refresh on turbo:morph.
|
|
13
|
+
export default class extends Controller {
|
|
14
|
+
static targets = ["button", "error", "unsupported"]
|
|
15
|
+
static values = {
|
|
16
|
+
challengeUrl: String,
|
|
17
|
+
url: String,
|
|
18
|
+
errorMessage: String,
|
|
19
|
+
duplicateMessage: String,
|
|
20
|
+
rpId: String,
|
|
21
|
+
userId: String,
|
|
22
|
+
userName: String,
|
|
23
|
+
displayName: String,
|
|
24
|
+
credentialIds: Array
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
connect() {
|
|
28
|
+
this.refresh()
|
|
29
|
+
if (supported()) this.#signalAccountState()
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
refresh() {
|
|
33
|
+
this.buttonTarget.hidden = !supported()
|
|
34
|
+
this.unsupportedTarget.hidden = supported()
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async register() {
|
|
38
|
+
this.buttonTarget.disabled = true
|
|
39
|
+
this.#showError(null)
|
|
40
|
+
|
|
41
|
+
try {
|
|
42
|
+
const options = await post(this.challengeUrlValue)
|
|
43
|
+
// Sign in again first: a full load, so the new session gets a fresh page.
|
|
44
|
+
if (options.body.redirect_url) return window.location.assign(options.body.redirect_url)
|
|
45
|
+
if (!options.ok) return this.#showError(options.body.error)
|
|
46
|
+
|
|
47
|
+
let credential
|
|
48
|
+
try {
|
|
49
|
+
credential = await navigator.credentials.create({
|
|
50
|
+
publicKey: PublicKeyCredential.parseCreationOptionsFromJSON(options.body)
|
|
51
|
+
})
|
|
52
|
+
} catch (error) {
|
|
53
|
+
// Cancelled, or this device already holds a passkey for the account.
|
|
54
|
+
if (error.name === "NotAllowedError") return
|
|
55
|
+
if (error.name === "InvalidStateError") return this.#showError(this.duplicateMessageValue)
|
|
56
|
+
return this.#showError()
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const result = await post(this.urlValue, { credential: credential.toJSON() })
|
|
60
|
+
if (result.body.redirect_url) return visit(result.body.redirect_url)
|
|
61
|
+
this.#showError(result.body.error)
|
|
62
|
+
} finally {
|
|
63
|
+
this.buttonTarget.disabled = false
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
#signalAccountState() {
|
|
68
|
+
const { rpIdValue: rpId, userIdValue: userId } = this
|
|
69
|
+
|
|
70
|
+
PublicKeyCredential.signalAllAcceptedCredentials?.({
|
|
71
|
+
rpId, userId, allAcceptedCredentialIds: this.credentialIdsValue
|
|
72
|
+
}).catch(() => {})
|
|
73
|
+
PublicKeyCredential.signalCurrentUserDetails?.({
|
|
74
|
+
rpId, userId, name: this.userNameValue, displayName: this.displayNameValue
|
|
75
|
+
}).catch(() => {})
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
#showError(message = this.errorMessageValue) {
|
|
79
|
+
this.errorTarget.textContent = message || ""
|
|
80
|
+
this.errorTarget.hidden = !message
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// JSON POST with the CSRF token; a network failure reads as an empty error.
|
|
85
|
+
async function post(url, body) {
|
|
86
|
+
try {
|
|
87
|
+
const response = await fetch(url, {
|
|
88
|
+
method: "POST",
|
|
89
|
+
headers: {
|
|
90
|
+
"Content-Type": "application/json",
|
|
91
|
+
Accept: "application/json",
|
|
92
|
+
"X-CSRF-Token": document.querySelector("meta[name=csrf-token]")?.content
|
|
93
|
+
},
|
|
94
|
+
body: body && JSON.stringify(body)
|
|
95
|
+
})
|
|
96
|
+
return { ok: response.ok, status: response.status, body: await response.json().catch(() => ({})) }
|
|
97
|
+
} catch {
|
|
98
|
+
return { ok: false, status: 0, body: {} }
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function visit(url) {
|
|
103
|
+
window.Turbo ? window.Turbo.visit(url) : window.location.assign(url)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function supported() {
|
|
107
|
+
return typeof PublicKeyCredential !== "undefined" &&
|
|
108
|
+
typeof PublicKeyCredential.parseCreationOptionsFromJSON === "function"
|
|
109
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
|
|
3
|
+
// Passkey sign-in (maquina:passkeys).
|
|
4
|
+
//
|
|
5
|
+
// On connect it starts a conditional request: the browser offers saved
|
|
6
|
+
// passkeys in the email field's autofill (autocomplete="username webauthn").
|
|
7
|
+
// The button starts a modal request instead. Both use the browser's own JSON
|
|
8
|
+
// helpers, so no WebAuthn library is needed. Cancelling the prompt is a
|
|
9
|
+
// normal outcome, not an error.
|
|
10
|
+
//
|
|
11
|
+
// The block is rendered visible and only hidden where passkeys are not
|
|
12
|
+
// supported; the view calls refresh on turbo:morph, which puts the server's
|
|
13
|
+
// attributes back without reconnecting this controller. A successful sign-in
|
|
14
|
+
// loads the next page in full, so it starts with the new session's CSRF token
|
|
15
|
+
// and no Turbo snapshot of the signed-out page.
|
|
16
|
+
export default class extends Controller {
|
|
17
|
+
static targets = ["button", "error"]
|
|
18
|
+
static values = { challengeUrl: String, url: String, errorMessage: String }
|
|
19
|
+
|
|
20
|
+
connect() {
|
|
21
|
+
this.refresh()
|
|
22
|
+
if (supported()) this.#autofill()
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
refresh() {
|
|
26
|
+
this.element.hidden = !supported()
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
disconnect() {
|
|
30
|
+
this.#abort()
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async signIn() {
|
|
34
|
+
this.buttonTarget.disabled = true
|
|
35
|
+
try {
|
|
36
|
+
await this.#authenticate()
|
|
37
|
+
} finally {
|
|
38
|
+
this.buttonTarget.disabled = false
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async #autofill() {
|
|
43
|
+
if (!(await PublicKeyCredential.isConditionalMediationAvailable?.())) return
|
|
44
|
+
|
|
45
|
+
await this.#authenticate("conditional")
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async #authenticate(mediation) {
|
|
49
|
+
this.#abort()
|
|
50
|
+
const abortController = this.abortController = new AbortController()
|
|
51
|
+
if (!mediation) this.#showError(null)
|
|
52
|
+
|
|
53
|
+
let credential
|
|
54
|
+
try {
|
|
55
|
+
const options = await post(this.challengeUrlValue)
|
|
56
|
+
if (!options.ok) return this.#showError(options.body.error)
|
|
57
|
+
|
|
58
|
+
credential = await navigator.credentials.get({
|
|
59
|
+
publicKey: PublicKeyCredential.parseRequestOptionsFromJSON(options.body),
|
|
60
|
+
signal: abortController.signal,
|
|
61
|
+
...(mediation && { mediation })
|
|
62
|
+
})
|
|
63
|
+
} catch (error) {
|
|
64
|
+
if (abortController.signal.aborted) return
|
|
65
|
+
if (error.name !== "NotAllowedError") return this.#showError()
|
|
66
|
+
// The modal prompt was cancelled: offer passkeys in autofill again.
|
|
67
|
+
if (!mediation) this.#autofill()
|
|
68
|
+
return
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const result = await post(this.urlValue, { credential: credential.toJSON() })
|
|
72
|
+
if (result.body.redirect_url) return window.location.assign(result.body.redirect_url)
|
|
73
|
+
|
|
74
|
+
if (result.status === 404 && result.body.credential_id) {
|
|
75
|
+
// Deleted on the server: ask the passkey provider to drop it too.
|
|
76
|
+
PublicKeyCredential.signalUnknownCredential?.({ rpId: result.body.rp_id, credentialId: result.body.credential_id })
|
|
77
|
+
}
|
|
78
|
+
this.#showError(result.body.error)
|
|
79
|
+
this.#autofill()
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
#abort() {
|
|
83
|
+
this.abortController?.abort()
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
#showError(message = this.errorMessageValue) {
|
|
87
|
+
this.errorTarget.textContent = message || ""
|
|
88
|
+
this.errorTarget.hidden = !message
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// JSON POST with the CSRF token; a network failure reads as an empty error.
|
|
93
|
+
async function post(url, body) {
|
|
94
|
+
try {
|
|
95
|
+
const response = await fetch(url, {
|
|
96
|
+
method: "POST",
|
|
97
|
+
headers: {
|
|
98
|
+
"Content-Type": "application/json",
|
|
99
|
+
Accept: "application/json",
|
|
100
|
+
"X-CSRF-Token": document.querySelector("meta[name=csrf-token]")?.content
|
|
101
|
+
},
|
|
102
|
+
body: body && JSON.stringify(body)
|
|
103
|
+
})
|
|
104
|
+
return { ok: response.ok, status: response.status, body: await response.json().catch(() => ({})) }
|
|
105
|
+
} catch {
|
|
106
|
+
return { ok: false, status: 0, body: {} }
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function supported() {
|
|
111
|
+
return typeof PublicKeyCredential !== "undefined" &&
|
|
112
|
+
typeof PublicKeyCredential.parseRequestOptionsFromJSON === "function"
|
|
113
|
+
}
|
|
@@ -1,58 +1,2 @@
|
|
|
1
1
|
class EmailVerification < ApplicationRecord
|
|
2
|
-
VERIFICATION_TYPES = %w[signup login].freeze
|
|
3
|
-
COOLDOWN_MINUTES = 15
|
|
4
|
-
MAX_ATTEMPTS = 5
|
|
5
|
-
|
|
6
|
-
validates :email, presence: true, format: {with: URI::MailTo::EMAIL_REGEXP}
|
|
7
|
-
validates :code, presence: true
|
|
8
|
-
validates :verification_type, presence: true, inclusion: {in: VERIFICATION_TYPES}
|
|
9
|
-
validates :expires_at, presence: true
|
|
10
|
-
|
|
11
|
-
normalizes :email, with: ->(email) { email.strip.downcase }
|
|
12
|
-
normalizes :code, with: ->(code) { code.strip.upcase }
|
|
13
|
-
|
|
14
|
-
# Encrypted at rest, not digested: the mail goes out through deliver_later,
|
|
15
|
-
# whose job reloads this row by GlobalID and must read the code back.
|
|
16
|
-
encrypts :code
|
|
17
|
-
|
|
18
|
-
scope :pending, -> { where(verified_at: nil) }
|
|
19
|
-
scope :expired, -> { where("expires_at < ?", Time.current) }
|
|
20
|
-
scope :active, -> { pending.where("expires_at >= ?", Time.current) }
|
|
21
|
-
scope :for_email, ->(email) { where("LOWER(email) = ?", email.to_s.downcase) }
|
|
22
|
-
scope :recent, -> { pending.where("created_at > ?", COOLDOWN_MINUTES.minutes.ago) }
|
|
23
|
-
|
|
24
|
-
def expired?
|
|
25
|
-
expires_at < Time.current
|
|
26
|
-
end
|
|
27
|
-
|
|
28
|
-
# A code is single-use against a bounded number of guesses: once the wrong-code
|
|
29
|
-
# count hits the ceiling the record is dead regardless of how many IPs an
|
|
30
|
-
# attacker rotates through, since each code only ever maps to one row.
|
|
31
|
-
def locked?
|
|
32
|
-
attempts >= MAX_ATTEMPTS
|
|
33
|
-
end
|
|
34
|
-
|
|
35
|
-
# Constant-time, so response timing reveals nothing about how close a guess
|
|
36
|
-
# came. Normalized the same way the stored code is.
|
|
37
|
-
def code_matches?(candidate)
|
|
38
|
-
ActiveSupport::SecurityUtils.secure_compare(code, candidate.to_s.strip.upcase)
|
|
39
|
-
end
|
|
40
|
-
|
|
41
|
-
def verified?
|
|
42
|
-
verified_at.present?
|
|
43
|
-
end
|
|
44
|
-
|
|
45
|
-
def can_resend?
|
|
46
|
-
created_at <= COOLDOWN_MINUTES.minutes.ago
|
|
47
|
-
end
|
|
48
|
-
|
|
49
|
-
def minutes_until_resend
|
|
50
|
-
return 0 if can_resend?
|
|
51
|
-
|
|
52
|
-
((created_at + COOLDOWN_MINUTES.minutes - Time.current) / 60).ceil
|
|
53
|
-
end
|
|
54
|
-
|
|
55
|
-
def mark_verified!
|
|
56
|
-
update!(verified_at: Time.current)
|
|
57
|
-
end
|
|
58
2
|
end
|