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.
Files changed (53) hide show
  1. checksums.yaml +4 -4
  2. data/lib/generators/maquina/passkeys/passkeys_generator.rb +3 -2
  3. data/lib/generators/maquina/passkeys/templates/app/controllers/passkeys_controller.rb +2 -2
  4. data/lib/generators/maquina/passkeys/templates/app/views/passkeys/index.html.erb +1 -1
  5. data/lib/maquina_generators/version.rb +1 -1
  6. data/test/generators/maquina/passkeys_generator_test.rb +14 -0
  7. data/test/tmp/Gemfile +2 -1
  8. data/test/tmp/app/controllers/concerns/authentication.rb +0 -61
  9. data/test/tmp/app/controllers/concerns/passkey_challenge.rb +42 -0
  10. data/test/tmp/app/controllers/concerns/passkey_reauthentication.rb +26 -0
  11. data/test/tmp/app/controllers/passkey_challenges_controller.rb +24 -0
  12. data/test/tmp/app/controllers/passkeys_controller.rb +68 -0
  13. data/test/tmp/app/controllers/session/passkey_challenges_controller.rb +18 -0
  14. data/test/tmp/app/controllers/session/passkeys_controller.rb +55 -0
  15. data/test/tmp/app/javascript/controllers/passkey_registration_controller.js +109 -0
  16. data/test/tmp/app/javascript/controllers/passkey_sign_in_controller.js +113 -0
  17. data/test/tmp/app/models/email_verification.rb +0 -56
  18. data/test/tmp/app/models/passkey.rb +36 -0
  19. data/test/tmp/app/models/user.rb +10 -0
  20. data/test/tmp/app/views/passkeys/index.html.erb +77 -0
  21. data/test/tmp/app/views/sessions/_passkey.html.erb +23 -0
  22. data/test/tmp/app/views/sessions/new.html.erb +37 -0
  23. data/test/tmp/config/initializers/rack_attack.rb +9 -0
  24. data/test/tmp/config/initializers/webauthn.rb +23 -0
  25. data/test/tmp/config/locales/passkeys.en.yml +46 -0
  26. data/test/tmp/config/locales/passkeys.es.yml +46 -0
  27. data/test/tmp/config/routes.rb +6 -9
  28. data/test/tmp/db/migrate/20261014213621_add_webauthn_id_to_users.rb +6 -0
  29. data/test/tmp/db/migrate/20261014213622_create_passkeys.rb +20 -0
  30. data/test/tmp/test/integration/passkeys_test.rb +99 -0
  31. data/test/tmp/test/test_helper.rb +4 -0
  32. data/test/tmp/test/test_helpers/webauthn_test_helper.rb +38 -0
  33. metadata +22 -21
  34. data/test/tmp/app/controllers/application_controller.rb +0 -3
  35. data/test/tmp/app/controllers/registration/verification_resends_controller.rb +0 -38
  36. data/test/tmp/app/controllers/registration/verifications_controller.rb +0 -64
  37. data/test/tmp/app/controllers/registrations_controller.rb +0 -63
  38. data/test/tmp/app/controllers/session/verification_resends_controller.rb +0 -44
  39. data/test/tmp/app/controllers/session/verifications_controller.rb +0 -64
  40. data/test/tmp/app/controllers/sessions_controller.rb +0 -56
  41. data/test/tmp/app/helpers/authentication_helper.rb +0 -20
  42. data/test/tmp/app/jobs/authentication_cleanup_job.rb +0 -13
  43. data/test/tmp/app/mailers/verification_mailer.rb +0 -15
  44. data/test/tmp/app/models/account.rb +0 -5
  45. data/test/tmp/app/models/current.rb +0 -5
  46. data/test/tmp/app/models/session.rb +0 -18
  47. data/test/tmp/config/locales/clave.en.yml +0 -102
  48. data/test/tmp/config/locales/clave.es.yml +0 -102
  49. data/test/tmp/db/migrate/20261014212613_create_accounts.rb +0 -9
  50. data/test/tmp/db/migrate/20261014212614_create_users.rb +0 -17
  51. data/test/tmp/db/migrate/20261014212615_create_sessions.rb +0 -12
  52. data/test/tmp/db/migrate/20261014212616_create_email_verifications.rb +0 -17
  53. 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: 8127a7f3efbfaea2bb59f4c0b0977476fd7bc4d176d634d294d9520dd3a10f83
4
- data.tar.gz: ef59f91d08b1f3bb99343f28564e5d4c07972201724e3c58a7ee37b1ab93c07f
3
+ metadata.gz: 7e7ab2221409345c84231d6908e18f5f14b265d32a10229f288f23d4c5a03006
4
+ data.tar.gz: b4966e9de4099d0664da18fd6ba2818b560770cdb2dab121a9f06265e38b4769
5
5
  SHA512:
6
- metadata.gz: 61d953a6567afb896d605dcf653bd8cd71d192fa81d043262157b32f915847f70e22bc74575cc35468f093b00532a0d910e39108fc6d0224ae7a6b5813fe832b
7
- data.tar.gz: 7e65996ad9c0e7e61bbd8ce5774432df06a8176005741b68ba2c3757cbaffa42439e1bacae486b5966e4b3801470d8d506e5a6c019c2bb4d95e9615cb2beb7a2
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: "destructive" },
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 %>
@@ -1,3 +1,3 @@
1
1
  module MaquinaGenerators
2
- VERSION = "0.8.0"
2
+ VERSION = "0.8.1"
3
3
  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,2 +1,3 @@
1
1
  source "https://rubygems.org"
2
- # gem "bcrypt"
2
+
3
+ gem "webauthn", "~> 3.4"
@@ -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