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
@@ -0,0 +1,36 @@
1
+ # A WebAuthn credential that signs its owner in (generated by maquina:passkeys).
2
+ #
3
+ # Only the public key is stored; the private key never leaves the device or
4
+ # the password manager, so a leaked table cannot be used to sign in.
5
+ class Passkey < ApplicationRecord
6
+ # AAGUIDs of common passkey providers, to give a new passkey a useful
7
+ # default name. Providers that send no AAGUID fall back to "Passkey".
8
+ PROVIDERS = {
9
+ "fbfc3007-154e-4ecc-8c0b-6e020557d7bd" => "iCloud Keychain",
10
+ "ea9b8d66-4d01-1d21-3ce4-b6b48cb575d4" => "Google Password Manager",
11
+ "adce0002-35bc-c60a-648b-0b25f1f05503" => "Chrome on Mac",
12
+ "08987058-cadc-4b81-b6e1-30de50dcbe96" => "Windows Hello",
13
+ "bada5566-a7aa-401f-bd96-45619a55120d" => "1Password",
14
+ "d548826e-79b4-db40-a3d8-11116f7e8349" => "Bitwarden"
15
+ }.freeze
16
+
17
+ belongs_to :user
18
+
19
+ validates :credential_id, presence: true, uniqueness: true
20
+ validates :public_key, presence: true
21
+ validates :name, presence: true, length: {maximum: 100}
22
+
23
+ def self.default_name(aaguid)
24
+ PROVIDERS.fetch(aaguid, "Passkey")
25
+ end
26
+
27
+ def record_use!(sign_count)
28
+ update!(sign_count: sign_count, last_used_at: Time.current)
29
+ end
30
+
31
+ # Synced through a provider (iCloud Keychain, Google Password Manager, a
32
+ # password manager) rather than bound to one device.
33
+ def synced?
34
+ backed_up?
35
+ end
36
+ end
@@ -1,6 +1,7 @@
1
1
  class User < ApplicationRecord
2
2
  belongs_to :account
3
3
  has_many :sessions, dependent: :destroy
4
+ has_many :passkeys, dependent: :destroy
4
5
 
5
6
  enum :role, {member: "member", admin: "admin"}, default: "member"
6
7
 
@@ -25,6 +26,15 @@ class User < ApplicationRecord
25
26
  update!(blocked_at: nil, blocked_reason: nil)
26
27
  end
27
28
 
29
+ # The WebAuthn user handle: random and stable, never the id or the email,
30
+ # since authenticators store it and hand it back on every sign-in.
31
+ def webauthn_id
32
+ super || begin
33
+ update_column(:webauthn_id, WebAuthn.generate_user_id)
34
+ super
35
+ end
36
+ end
37
+
28
38
  private
29
39
 
30
40
  def email_cannot_contain_plus
@@ -0,0 +1,77 @@
1
+ <div class="flex flex-1 justify-center px-4 py-8">
2
+ <div class="w-full max-w-2xl space-y-6"
3
+ data-controller="passkey-registration"
4
+ data-action="turbo:morph@document->passkey-registration#refresh"
5
+ data-passkey-registration-challenge-url-value="<%= passkey_challenge_path %>"
6
+ data-passkey-registration-url-value="<%= passkeys_path %>"
7
+ data-passkey-registration-error-message-value="<%= t(".error") %>"
8
+ data-passkey-registration-duplicate-message-value="<%= t(".duplicate") %>"
9
+ data-passkey-registration-rp-id-value="<%= WebAuthn.configuration.rp_id %>"
10
+ data-passkey-registration-user-id-value="<%= Current.user.webauthn_id %>"
11
+ data-passkey-registration-user-name-value="<%= Current.user.email_address %>"
12
+ data-passkey-registration-display-name-value="<%= Current.user.name.presence || Current.user.email_address %>"
13
+ data-passkey-registration-credential-ids-value="<%= @passkeys.map(&:credential_id).to_json %>">
14
+ <div class="flex flex-wrap items-start justify-between gap-4">
15
+ <div>
16
+ <h1 class="text-2xl font-bold text-foreground"><%= t(".title") %></h1>
17
+ <p class="mt-1 text-sm text-muted-foreground"><%= t(".description") %></p>
18
+ </div>
19
+
20
+ <button type="button"
21
+ data-action="passkey-registration#register"
22
+ data-passkey-registration-target="button"
23
+ data-component="button"
24
+ data-variant="primary"
25
+ class="px-4 py-2 text-sm font-medium text-primary-foreground bg-primary hover:bg-primary/90 rounded-lg transition-colors cursor-pointer disabled:opacity-50">
26
+ <%= t(".add") %>
27
+ </button>
28
+ </div>
29
+
30
+ <p data-passkey-registration-target="error" class="text-sm text-destructive" role="alert" hidden></p>
31
+ <p data-passkey-registration-target="unsupported" class="text-sm text-muted-foreground" hidden><%= t(".unsupported") %></p>
32
+
33
+ <% if @passkeys.empty? %>
34
+ <div class="bg-card rounded-xl border border-border p-8 text-center">
35
+ <p class="text-sm text-muted-foreground"><%= t(".empty") %></p>
36
+ </div>
37
+ <% else %>
38
+ <ul class="bg-card rounded-xl border border-border divide-y divide-border">
39
+ <% @passkeys.each do |passkey| %>
40
+ <li id="<%= dom_id(passkey) %>" class="flex flex-wrap items-center gap-4 p-4">
41
+ <%= form_with model: passkey, class: "flex flex-1 items-center gap-2 min-w-0" do |form| %>
42
+ <%= form.text_field :name, required: true, maxlength: 100,
43
+ aria: { label: Passkey.human_attribute_name(:name) },
44
+ data: { component: "input" },
45
+ class: "min-w-0 flex-1 px-3 py-2 text-sm border border-input rounded-lg focus:ring-2 focus:ring-ring focus:border-ring" %>
46
+ <%= form.submit t(".rename"), data: { component: "button", variant: "ghost" },
47
+ class: "px-3 py-2 text-sm font-medium text-foreground hover:bg-accent rounded-lg cursor-pointer" %>
48
+ <% end %>
49
+
50
+ <div class="text-xs text-muted-foreground space-y-1">
51
+ <p>
52
+ <%= t(".created", date: l(passkey.created_at.to_date, format: :long)) %>
53
+ <% if passkey.synced? %>
54
+ · <span data-component="badge" class="font-medium"><%= t(".synced") %></span>
55
+ <% end %>
56
+ </p>
57
+ <p>
58
+ <%= passkey.last_used_at ? t(".last_used", time: time_ago_in_words(passkey.last_used_at)) : t(".never_used") %>
59
+ </p>
60
+ </div>
61
+
62
+ <%= button_to t(".remove"), passkey_path(passkey), method: :delete,
63
+ form: { data: { turbo_confirm: t(".remove_confirm") } },
64
+ data: { component: "button", variant: "ghost" },
65
+ class: "px-3 py-2 text-sm font-medium text-destructive hover:bg-destructive/10 rounded-lg cursor-pointer" %>
66
+ </li>
67
+ <% end %>
68
+ </ul>
69
+
70
+ <% if @passkeys.one? && !@passkeys.first.synced? %>
71
+ <p class="text-sm text-muted-foreground"><%= t(".add_another") %></p>
72
+ <% end %>
73
+ <% end %>
74
+
75
+ <p class="text-sm text-muted-foreground"><%= t(".fallback") %></p>
76
+ </div>
77
+ </div>
@@ -0,0 +1,23 @@
1
+ <div data-controller="passkey-sign-in"
2
+ data-action="turbo:morph@document->passkey-sign-in#refresh"
3
+ data-passkey-sign-in-challenge-url-value="<%= session_passkey_challenge_path %>"
4
+ data-passkey-sign-in-url-value="<%= session_passkey_path %>"
5
+ data-passkey-sign-in-error-message-value="<%= t("sessions.passkey.error") %>"
6
+ class="mt-6 space-y-4">
7
+ <div class="flex items-center gap-3 text-sm text-muted-foreground">
8
+ <span class="h-px flex-1 bg-border"></span>
9
+ <%= t("sessions.passkey.or") %>
10
+ <span class="h-px flex-1 bg-border"></span>
11
+ </div>
12
+
13
+ <button type="button"
14
+ data-action="passkey-sign-in#signIn"
15
+ data-passkey-sign-in-target="button"
16
+ data-component="button"
17
+ data-variant="outline"
18
+ class="w-full px-4 py-3 text-base font-medium text-foreground bg-background border border-input hover:bg-accent rounded-lg transition-colors cursor-pointer disabled:opacity-50">
19
+ <%= t("sessions.passkey.sign_in") %>
20
+ </button>
21
+
22
+ <p data-passkey-sign-in-target="error" class="text-sm text-destructive text-center" role="alert" hidden></p>
23
+ </div>
@@ -0,0 +1,37 @@
1
+ <div class="flex flex-1 items-center justify-center px-4 py-8">
2
+ <div class="w-full max-w-md">
3
+ <div class="bg-card rounded-xl shadow-sm border border-border p-8">
4
+ <h1 class="text-2xl font-bold text-foreground text-center mb-8">
5
+ <%= t(".title") %>
6
+ </h1>
7
+
8
+ <%= form_with url: session_path, method: :post, class: "space-y-6" do |form| %>
9
+ <div>
10
+ <%= form.label :email_address, t(".email"), class: "block text-sm font-medium text-foreground mb-2", data: { component: "label" } %>
11
+ <%= form.email_field :email_address,
12
+ required: true,
13
+ autofocus: true,
14
+ autocomplete: "username webauthn",
15
+ placeholder: t(".email_placeholder"),
16
+ value: params[:email_address],
17
+ data: { component: "input" },
18
+ class: "w-full px-4 py-3 border border-input rounded-lg focus:ring-2 focus:ring-ring focus:border-ring transition-colors" %>
19
+ </div>
20
+
21
+ <p class="text-sm text-muted-foreground">
22
+ <%= t(".email_instructions") %>
23
+ </p>
24
+
25
+ <p class="text-sm text-muted-foreground">
26
+ <%= t(".spam_note") %>
27
+ </p>
28
+
29
+ <div>
30
+ <%= form.submit t(".submit"), data: { component: "button", variant: "primary" }, class: "w-full px-4 py-3 text-base font-medium text-primary-foreground bg-primary hover:bg-primary/90 rounded-lg transition-colors cursor-pointer" %>
31
+ </div>
32
+ <% end %>
33
+
34
+ <%= render "sessions/passkey" %>
35
+ </div>
36
+ </div>
37
+ </div>
@@ -0,0 +1,9 @@
1
+ class Rack::Attack
2
+ end
3
+
4
+ # Passkey sign-in (maquina:passkeys): the challenge and the assertion,
5
+ # counted like the email-code login above. Keyed on the address Rails
6
+ # resolves through trusted proxies, not the proxy hop in req.ip.
7
+ Rack::Attack.throttle("passkey/ip", limit: 20, period: 1.minute) do |req|
8
+ (req.env["action_dispatch.remote_ip"]&.to_s || req.ip) if req.path.start_with?("/session/passkey") && req.post?
9
+ end
@@ -0,0 +1,23 @@
1
+ # Passkeys (maquina:passkeys)
2
+ #
3
+ # A passkey is bound to its relying party: rp_id is the domain it belongs to,
4
+ # and the browser only offers it on pages from allowed_origins. rp_id can be
5
+ # a parent of the origins' host (example.com for https://app.example.com),
6
+ # never another domain. Changing rp_id later orphans every saved passkey.
7
+ #
8
+ # Development and test use localhost; production reads WEBAUTHN_RP_ID and
9
+ # WEBAUTHN_ORIGINS (comma-separated), or webauthn: {rp_id:, origins:} in the
10
+ # credentials.
11
+ webauthn = Rails.application.credentials.webauthn || {}
12
+
13
+ WebAuthn.configure do |config|
14
+ config.rp_name = ENV.fetch("WEBAUTHN_RP_NAME") { Rails.application.class.module_parent_name.titleize }
15
+ config.rp_id = ENV["WEBAUTHN_RP_ID"] || webauthn[:rp_id] || "localhost"
16
+ config.allowed_origins = Array(ENV["WEBAUTHN_ORIGINS"]&.split(",") || webauthn[:origins] || "http://localhost:3000")
17
+ end
18
+
19
+ # A production app still on localhost would accept no passkey at all. The
20
+ # asset build runs in production without the real settings, so it is spared.
21
+ if Rails.env.production? && WebAuthn.configuration.rp_id == "localhost" && ENV["SECRET_KEY_BASE_DUMMY"].blank?
22
+ raise "Passkeys: set WEBAUTHN_RP_ID and WEBAUTHN_ORIGINS (see config/initializers/webauthn.rb)"
23
+ end
@@ -0,0 +1,46 @@
1
+ en:
2
+ flash:
3
+ passkeys:
4
+ created: "Passkey added. You can now sign in with it."
5
+ updated: "Passkey renamed."
6
+ destroyed: "Passkey removed."
7
+ failed: "We couldn't sign you in with that passkey. Try again or use your email."
8
+ unknown: "That passkey is no longer linked to an account. Sign in with your email instead."
9
+ challenge_expired: "The request expired. Please try again."
10
+ registration_failed: "We couldn't add that passkey. Please try again."
11
+ reauthenticate: "Sign in again to manage your passkeys."
12
+
13
+ sessions:
14
+ passkey:
15
+ or: "or"
16
+ sign_in: "Sign in with a passkey"
17
+ error: "Something went wrong with the passkey. Please try again."
18
+
19
+ passkeys:
20
+ index:
21
+ title: "Passkeys"
22
+ description: "Sign in with Face ID, a fingerprint or your device PIN instead of an email code."
23
+ add: "Add a passkey"
24
+ empty: "You don't have any passkeys yet."
25
+ rename: "Save"
26
+ remove: "Remove"
27
+ remove_confirm: "Remove this passkey? You won't be able to sign in with it anymore."
28
+ created: "Added %{date}"
29
+ synced: "Synced"
30
+ last_used: "Last used %{time} ago"
31
+ never_used: "Not used yet"
32
+ add_another: "This passkey lives on one device. Add another so you can still sign in if you lose it."
33
+ fallback: "You can always sign in with a code sent to your email."
34
+ unsupported: "This browser doesn't support passkeys."
35
+ error: "Something went wrong with the passkey. Please try again."
36
+ duplicate: "This device already has a passkey for your account."
37
+
38
+ activerecord:
39
+ models:
40
+ passkey:
41
+ one: "Passkey"
42
+ other: "Passkeys"
43
+ attributes:
44
+ passkey:
45
+ name: "Name"
46
+ last_used_at: "Last used"
@@ -0,0 +1,46 @@
1
+ es:
2
+ flash:
3
+ passkeys:
4
+ created: "Llave de acceso agregada. Ya puedes iniciar sesión con ella."
5
+ updated: "Llave de acceso renombrada."
6
+ destroyed: "Llave de acceso eliminada."
7
+ failed: "No pudimos iniciar tu sesión con esa llave de acceso. Inténtalo de nuevo o usa tu correo."
8
+ unknown: "Esa llave de acceso ya no está vinculada a una cuenta. Inicia sesión con tu correo."
9
+ challenge_expired: "La solicitud expiró. Inténtalo de nuevo."
10
+ registration_failed: "No pudimos agregar esa llave de acceso. Inténtalo de nuevo."
11
+ reauthenticate: "Inicia sesión de nuevo para administrar tus llaves de acceso."
12
+
13
+ sessions:
14
+ passkey:
15
+ or: "o"
16
+ sign_in: "Iniciar sesión con llave de acceso"
17
+ error: "Algo salió mal con la llave de acceso. Inténtalo de nuevo."
18
+
19
+ passkeys:
20
+ index:
21
+ title: "Llaves de acceso"
22
+ description: "Inicia sesión con Face ID, tu huella o el PIN de tu dispositivo en lugar de un código por correo."
23
+ add: "Agregar llave de acceso"
24
+ empty: "Todavía no tienes llaves de acceso."
25
+ rename: "Guardar"
26
+ remove: "Eliminar"
27
+ remove_confirm: "¿Eliminar esta llave de acceso? Ya no podrás iniciar sesión con ella."
28
+ created: "Agregada el %{date}"
29
+ synced: "Sincronizada"
30
+ last_used: "Usada hace %{time}"
31
+ never_used: "Sin usar"
32
+ add_another: "Esta llave de acceso vive en un solo dispositivo. Agrega otra para no perder el acceso si lo pierdes."
33
+ fallback: "Siempre puedes iniciar sesión con un código enviado a tu correo."
34
+ unsupported: "Este navegador no es compatible con llaves de acceso."
35
+ error: "Algo salió mal con la llave de acceso. Inténtalo de nuevo."
36
+ duplicate: "Este dispositivo ya tiene una llave de acceso para tu cuenta."
37
+
38
+ activerecord:
39
+ models:
40
+ passkey:
41
+ one: "Llave de acceso"
42
+ other: "Llaves de acceso"
43
+ attributes:
44
+ passkey:
45
+ name: "Nombre"
46
+ last_used_at: "Último uso"
@@ -1,12 +1,9 @@
1
1
  Rails.application.routes.draw do
2
- # Authentication routes (generated by maquina:clave)
3
- resource :session, only: [:new, :create, :destroy] do
4
- resource :verification, only: [:new, :create], controller: "session/verifications"
5
- resource :verification_resend, only: [:create], controller: "session/verification_resends"
6
- end
7
-
8
- resource :registration, only: [:new, :create] do
9
- resource :verification, only: [:new, :create], controller: "registration/verifications"
10
- resource :verification_resend, only: [:create], controller: "registration/verification_resends"
2
+ # Passkeys (generated by maquina:passkeys)
3
+ resource :session, only: [] do
4
+ resource :passkey_challenge, only: :create, controller: "session/passkey_challenges"
5
+ resource :passkey, only: :create, controller: "session/passkeys"
11
6
  end
7
+ resource :passkey_challenge, only: :create
8
+ resources :passkeys, only: [:index, :create, :update, :destroy]
12
9
  end
@@ -0,0 +1,6 @@
1
+ class AddWebauthnIdToUsers < ActiveRecord::Migration[8.1]
2
+ def change
3
+ add_column :users, :webauthn_id, :string
4
+ add_index :users, :webauthn_id, unique: true
5
+ end
6
+ end
@@ -0,0 +1,20 @@
1
+ class CreatePasskeys < ActiveRecord::Migration[8.1]
2
+ def change
3
+ create_table :passkeys do |t|
4
+ t.references :user, null: false, foreign_key: true
5
+ t.string :credential_id, null: false
6
+ t.text :public_key, null: false
7
+ t.bigint :sign_count, null: false, default: 0
8
+ t.string :name, null: false
9
+ t.json :transports
10
+ t.string :aaguid, limit: 36
11
+ t.boolean :backup_eligible, null: false, default: false
12
+ t.boolean :backed_up, null: false, default: false
13
+ t.datetime :last_used_at
14
+
15
+ t.timestamps
16
+ end
17
+
18
+ add_index :passkeys, :credential_id, unique: true
19
+ end
20
+ end
@@ -0,0 +1,99 @@
1
+ require "test_helper"
2
+ require_relative "../test_helpers/session_test_helper"
3
+
4
+ # The passkey flow end to end (generated by maquina:passkeys), with
5
+ # WebAuthn::FakeClient as the browser and the authenticator.
6
+ class PasskeysTest < ActionDispatch::IntegrationTest
7
+ setup do
8
+ @user = User.create!(account: Account.create!(name: "Acme"), email_address: "passkey@example.com")
9
+ end
10
+
11
+ test "adds a passkey and signs in with it" do
12
+ sign_in_as @user
13
+ register_passkey
14
+ assert_response :created
15
+ passkey = @user.passkeys.sole
16
+
17
+ sign_out
18
+ sign_in_with_passkey(@user)
19
+
20
+ assert_response :success
21
+ assert response.parsed_body["redirect_url"].present?
22
+ assert cookies["session_id"].present?
23
+ assert passkey.reload.last_used_at
24
+ end
25
+
26
+ test "a challenge signs in once" do
27
+ sign_in_as @user
28
+ register_passkey
29
+ sign_out
30
+
31
+ post session_passkey_challenge_path, as: :json
32
+ assertion = webauthn_client.get(challenge: response.parsed_body["challenge"], rp_id: WebAuthn.configuration.rp_id,
33
+ user_verified: true, user_handle: Base64.urlsafe_decode64(@user.reload.webauthn_id))
34
+ post session_passkey_path, params: {credential: assertion}, as: :json
35
+ assert_response :success
36
+
37
+ post session_passkey_path, params: {credential: assertion}, as: :json
38
+ assert_response :unprocessable_entity
39
+ end
40
+
41
+ test "a passkey needs user verification" do
42
+ sign_in_as @user
43
+ post passkey_challenge_path, as: :json
44
+ credential = webauthn_client.create(challenge: response.parsed_body["challenge"], rp_id: WebAuthn.configuration.rp_id,
45
+ user_verified: false)
46
+
47
+ post passkeys_path, params: {credential: credential}, as: :json
48
+
49
+ assert_response :unprocessable_entity
50
+ assert_empty @user.passkeys
51
+ end
52
+
53
+ test "a removed passkey no longer signs in" do
54
+ sign_in_as @user
55
+ register_passkey
56
+ @user.passkeys.destroy_all
57
+ sign_out
58
+
59
+ sign_in_with_passkey(@user)
60
+
61
+ assert_response :not_found
62
+ assert response.parsed_body["credential_id"].present?
63
+ end
64
+
65
+ test "a blocked user cannot sign in with a passkey" do
66
+ sign_in_as @user
67
+ register_passkey
68
+ sign_out
69
+ @user.block!
70
+
71
+ sign_in_with_passkey(@user)
72
+
73
+ assert_response :unprocessable_entity
74
+ end
75
+
76
+ test "adding a passkey needs a recent sign-in" do
77
+ sign_in_as @user
78
+ Current.session.update_column(:created_at, 2.hours.ago)
79
+
80
+ post passkey_challenge_path, as: :json
81
+
82
+ assert_response :unauthorized
83
+ assert_equal new_session_url, response.parsed_body["redirect_url"]
84
+ end
85
+
86
+ test "renames and removes a passkey" do
87
+ sign_in_as @user
88
+ register_passkey
89
+ passkey = @user.passkeys.sole
90
+
91
+ patch passkey_path(passkey), params: {passkey: {name: "Work laptop"}}
92
+ assert_redirected_to passkeys_path
93
+ assert_equal "Work laptop", passkey.reload.name
94
+
95
+ delete passkey_path(passkey)
96
+ assert_redirected_to passkeys_path
97
+ assert_empty @user.passkeys.reload
98
+ end
99
+ end
@@ -0,0 +1,4 @@
1
+ ENV["RAILS_ENV"] ||= "test"
2
+ require_relative "../config/environment"
3
+ require "rails/test_help"
4
+ require_relative "test_helpers/webauthn_test_helper"
@@ -0,0 +1,38 @@
1
+ # Passkeys in integration tests (generated by maquina:passkeys).
2
+ #
3
+ # WebAuthn::FakeClient plays the browser and the authenticator, so tests go
4
+ # through the real endpoints: register_passkey needs a signed-in user
5
+ # (sign_in_as), sign_in_with_passkey needs a passkey the same client made.
6
+ #
7
+ # sign_in_as users(:one)
8
+ # register_passkey
9
+ # sign_out
10
+ # sign_in_with_passkey(users(:one))
11
+ # assert_response :success
12
+ require "webauthn/fake_client"
13
+
14
+ module WebauthnTestHelper
15
+ def webauthn_client
16
+ @webauthn_client ||= WebAuthn::FakeClient.new(WebAuthn.configuration.allowed_origins.first)
17
+ end
18
+
19
+ def register_passkey(client: webauthn_client)
20
+ post passkey_challenge_path, as: :json
21
+ credential = client.create(challenge: response.parsed_body["challenge"], rp_id: WebAuthn.configuration.rp_id,
22
+ user_verified: true)
23
+
24
+ post passkeys_path, params: {credential: credential}, as: :json
25
+ end
26
+
27
+ def sign_in_with_passkey(user, client: webauthn_client)
28
+ post session_passkey_challenge_path, as: :json
29
+ assertion = client.get(challenge: response.parsed_body["challenge"], rp_id: WebAuthn.configuration.rp_id,
30
+ user_verified: true, user_handle: Base64.urlsafe_decode64(user.reload.webauthn_id))
31
+
32
+ post session_passkey_path, params: {credential: assertion}, as: :json
33
+ end
34
+ end
35
+
36
+ ActiveSupport.on_load(:action_dispatch_integration_test) do
37
+ include WebauthnTestHelper
38
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: maquina-generators
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.8.0
4
+ version: 0.8.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mario Alberto Chavez
@@ -291,30 +291,31 @@ files:
291
291
  - test/generators/maquina/solid_queue_generator_test.rb
292
292
  - test/test_helper.rb
293
293
  - test/tmp/Gemfile
294
- - test/tmp/app/controllers/application_controller.rb
295
294
  - test/tmp/app/controllers/concerns/authentication.rb
296
- - test/tmp/app/controllers/registration/verification_resends_controller.rb
297
- - test/tmp/app/controllers/registration/verifications_controller.rb
298
- - test/tmp/app/controllers/registrations_controller.rb
299
- - test/tmp/app/controllers/session/verification_resends_controller.rb
300
- - test/tmp/app/controllers/session/verifications_controller.rb
301
- - test/tmp/app/controllers/sessions_controller.rb
302
- - test/tmp/app/helpers/authentication_helper.rb
303
- - test/tmp/app/jobs/authentication_cleanup_job.rb
304
- - test/tmp/app/mailers/verification_mailer.rb
305
- - test/tmp/app/models/account.rb
306
- - test/tmp/app/models/current.rb
295
+ - test/tmp/app/controllers/concerns/passkey_challenge.rb
296
+ - test/tmp/app/controllers/concerns/passkey_reauthentication.rb
297
+ - test/tmp/app/controllers/passkey_challenges_controller.rb
298
+ - test/tmp/app/controllers/passkeys_controller.rb
299
+ - test/tmp/app/controllers/session/passkey_challenges_controller.rb
300
+ - test/tmp/app/controllers/session/passkeys_controller.rb
301
+ - test/tmp/app/javascript/controllers/passkey_registration_controller.js
302
+ - test/tmp/app/javascript/controllers/passkey_sign_in_controller.js
307
303
  - test/tmp/app/models/email_verification.rb
308
- - test/tmp/app/models/session.rb
304
+ - test/tmp/app/models/passkey.rb
309
305
  - test/tmp/app/models/user.rb
310
- - test/tmp/config/locales/clave.en.yml
311
- - test/tmp/config/locales/clave.es.yml
306
+ - test/tmp/app/views/passkeys/index.html.erb
307
+ - test/tmp/app/views/sessions/_passkey.html.erb
308
+ - test/tmp/app/views/sessions/new.html.erb
309
+ - test/tmp/config/initializers/rack_attack.rb
310
+ - test/tmp/config/initializers/webauthn.rb
311
+ - test/tmp/config/locales/passkeys.en.yml
312
+ - test/tmp/config/locales/passkeys.es.yml
312
313
  - test/tmp/config/routes.rb
313
- - test/tmp/db/migrate/20261014212613_create_accounts.rb
314
- - test/tmp/db/migrate/20261014212614_create_users.rb
315
- - test/tmp/db/migrate/20261014212615_create_sessions.rb
316
- - test/tmp/db/migrate/20261014212616_create_email_verifications.rb
317
- - test/tmp/test/test_helpers/session_test_helper.rb
314
+ - test/tmp/db/migrate/20261014213621_add_webauthn_id_to_users.rb
315
+ - test/tmp/db/migrate/20261014213622_create_passkeys.rb
316
+ - test/tmp/test/integration/passkeys_test.rb
317
+ - test/tmp/test/test_helper.rb
318
+ - test/tmp/test/test_helpers/webauthn_test_helper.rb
318
319
  homepage: https://maquina.app
319
320
  licenses:
320
321
  - MIT
@@ -1,3 +0,0 @@
1
- class ApplicationController < ActionController::Base
2
- include Authentication
3
- end
@@ -1,38 +0,0 @@
1
- class Registration::VerificationResendsController < ApplicationController
2
- allow_unauthenticated_access
3
- rate_limit to: 5, within: 3.minutes, only: :create, with: -> { redirect_to new_registration_verification_path, alert: t("flash.general.rate_limited") }
4
-
5
- # POST /registration/verification_resend
6
- def create
7
- old_verification = EmailVerification.find_by(id: session[:pending_verification_id])
8
-
9
- unless old_verification
10
- redirect_to new_registration_path
11
- return
12
- end
13
-
14
- email = old_verification.email
15
-
16
- if EmailVerification.for_email(email).recent.exists?
17
- recent = EmailVerification.for_email(email).recent.first
18
- redirect_to new_registration_verification_path,
19
- alert: t("flash.registrations.cooldown", minutes: recent.minutes_until_resend)
20
- return
21
- end
22
-
23
- verification = EmailVerification.create!(
24
- email: email,
25
- code: SecureRandom.hex(3).upcase,
26
- verification_type: "signup",
27
- locale: old_verification.locale || I18n.locale.to_s,
28
- expires_at: 15.minutes.from_now
29
- )
30
-
31
- Rails.logger.info "[AUTH] Verification code resent for registration #{email} from #{request.remote_ip}"
32
- VerificationMailer.verification_code(verification).deliver_later
33
- session[:pending_verification_id] = verification.id
34
-
35
- redirect_to new_registration_verification_path,
36
- notice: t("flash.registrations.code_resent")
37
- end
38
- end