maquina-generators 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +84 -0
  3. data/lib/generators/maquina/clave/clave_generator.rb +48 -0
  4. data/lib/generators/maquina/clave/templates/app/controllers/registration/verifications_controller.rb.tt +10 -1
  5. data/lib/generators/maquina/clave/templates/app/controllers/session/verifications_controller.rb.tt +10 -1
  6. data/lib/generators/maquina/clave/templates/app/models/email_verification.rb.tt +18 -0
  7. data/lib/generators/maquina/clave/templates/app/views/sessions/new.html.erb.tt +2 -0
  8. data/lib/generators/maquina/clave/templates/config/locales/clave.en.yml +2 -0
  9. data/lib/generators/maquina/clave/templates/config/locales/clave.es.yml +2 -0
  10. data/lib/generators/maquina/csp_reports/USAGE +18 -0
  11. data/lib/generators/maquina/csp_reports/csp_reports_generator.rb +231 -0
  12. data/lib/generators/maquina/csp_reports/templates/app/controllers/csp_reports_controller.rb +24 -0
  13. data/lib/generators/maquina/csp_reports/templates/app/models/security/csp_violation.rb +89 -0
  14. data/lib/generators/maquina/csp_reports/templates/app/models/security/csp_violation_report.rb +30 -0
  15. data/lib/generators/maquina/csp_reports/templates/app/views/backstage/security/_csp_violations.html.erb +42 -0
  16. data/lib/generators/maquina/csp_reports/templates/db/create_csp_violations.rb.tt +23 -0
  17. data/lib/generators/maquina/passkeys/USAGE +18 -0
  18. data/lib/generators/maquina/passkeys/passkeys_generator.rb +259 -0
  19. data/lib/generators/maquina/passkeys/templates/app/controllers/concerns/passkey_challenge.rb +42 -0
  20. data/lib/generators/maquina/passkeys/templates/app/controllers/concerns/passkey_reauthentication.rb +26 -0
  21. data/lib/generators/maquina/passkeys/templates/app/controllers/passkey_challenges_controller.rb +24 -0
  22. data/lib/generators/maquina/passkeys/templates/app/controllers/passkeys_controller.rb +68 -0
  23. data/lib/generators/maquina/passkeys/templates/app/controllers/session/passkey_challenges_controller.rb +18 -0
  24. data/lib/generators/maquina/passkeys/templates/app/controllers/session/passkeys_controller.rb +55 -0
  25. data/lib/generators/maquina/passkeys/templates/app/javascript/controllers/passkey_registration_controller.js +109 -0
  26. data/lib/generators/maquina/passkeys/templates/app/javascript/controllers/passkey_sign_in_controller.js +113 -0
  27. data/lib/generators/maquina/passkeys/templates/app/models/passkey.rb +36 -0
  28. data/lib/generators/maquina/passkeys/templates/app/views/passkeys/index.html.erb +77 -0
  29. data/lib/generators/maquina/passkeys/templates/app/views/sessions/_passkey.html.erb +23 -0
  30. data/lib/generators/maquina/passkeys/templates/config/initializers/webauthn.rb +23 -0
  31. data/lib/generators/maquina/passkeys/templates/config/locales/passkeys.en.yml +46 -0
  32. data/lib/generators/maquina/passkeys/templates/config/locales/passkeys.es.yml +46 -0
  33. data/lib/generators/maquina/passkeys/templates/migration_add_webauthn_id_to_users.rb.tt +6 -0
  34. data/lib/generators/maquina/passkeys/templates/migration_create_passkeys.rb.tt +20 -0
  35. data/lib/generators/maquina/passkeys/templates/test/integration/passkeys_test.rb +99 -0
  36. data/lib/generators/maquina/passkeys/templates/test/test_helpers/webauthn_test_helper.rb +38 -0
  37. data/lib/maquina_generators/version.rb +1 -1
  38. data/test/generators/maquina/clave_generator_test.rb +87 -2
  39. data/test/generators/maquina/csp_reports_generator_test.rb +151 -0
  40. data/test/generators/maquina/passkeys_generator_test.rb +253 -0
  41. data/test/tmp/Gemfile +1 -2
  42. data/test/tmp/app/controllers/application_controller.rb +1 -0
  43. data/test/tmp/app/controllers/concerns/authentication.rb +63 -0
  44. data/test/tmp/app/controllers/registration/verification_resends_controller.rb +38 -0
  45. data/test/tmp/app/controllers/registration/verifications_controller.rb +64 -0
  46. data/test/tmp/app/controllers/registrations_controller.rb +47 -21
  47. data/test/tmp/app/controllers/session/verification_resends_controller.rb +44 -0
  48. data/test/tmp/app/controllers/session/verifications_controller.rb +64 -0
  49. data/test/tmp/app/controllers/sessions_controller.rb +56 -0
  50. data/test/tmp/app/helpers/authentication_helper.rb +20 -0
  51. data/test/tmp/app/jobs/authentication_cleanup_job.rb +13 -0
  52. data/test/tmp/app/mailers/verification_mailer.rb +15 -0
  53. data/test/tmp/app/models/email_verification.rb +58 -0
  54. data/test/tmp/app/models/session.rb +18 -0
  55. data/test/tmp/app/models/user.rb +28 -3
  56. data/test/tmp/config/locales/clave.en.yml +102 -0
  57. data/test/tmp/config/locales/clave.es.yml +102 -0
  58. data/test/tmp/config/routes.rb +10 -2
  59. data/test/tmp/db/migrate/20261014212614_create_users.rb +17 -0
  60. data/test/tmp/db/migrate/20261014212615_create_sessions.rb +12 -0
  61. data/test/tmp/db/migrate/20261014212616_create_email_verifications.rb +17 -0
  62. data/test/tmp/test/test_helpers/session_test_helper.rb +19 -0
  63. metadata +52 -10
  64. data/test/tmp/app/views/registrations/new.html.erb +0 -72
  65. data/test/tmp/app/views/sessions/new.html.erb +0 -46
  66. data/test/tmp/config/locales/registration.en.yml +0 -44
  67. data/test/tmp/config/locales/registration.es.yml +0 -44
  68. data/test/tmp/db/migrate/20260927024164_add_account_fields_to_users.rb +0 -7
  69. /data/test/tmp/db/migrate/{20260927024163_create_accounts.rb → 20261014212613_create_accounts.rb} +0 -0
@@ -0,0 +1,89 @@
1
+ # One Content Security Policy violation a browser reported to CspReportsController.
2
+ # Rows are written once and never updated; they are what tells you whether the
3
+ # policy is ready to enforce (report-only) or what it is breaking (enforced).
4
+ #
5
+ # Browsers send two shapes. The legacy `report-uri` one is a single object,
6
+ # `{"csp-report": {"blocked-uri": ..., ...}}` (Content-Type application/csp-report);
7
+ # the Reporting API one is an array of `{"type": "csp-violation", "body": {...}}`
8
+ # (application/reports+json) with camelCase keys. Both normalize to the same row.
9
+ class Security::CspViolation < Security::Record
10
+ RETENTION = 30.days
11
+
12
+ # Violations caused by the reader's own browser extensions say nothing about
13
+ # the app, and they are most of the volume.
14
+ NOISE_SCHEMES = %w[chrome-extension moz-extension safari-extension safari-web-extension ms-browser-extension].freeze
15
+
16
+ FIELD_LIMIT = 1024
17
+ SAMPLE_LIMIT = 256
18
+
19
+ LEGACY_KEYS = {
20
+ "document-uri" => :document_uri, "blocked-uri" => :blocked_uri,
21
+ "effective-directive" => :effective_directive, "violated-directive" => :effective_directive,
22
+ "disposition" => :disposition, "source-file" => :source_file,
23
+ "line-number" => :line_number, "column-number" => :column_number, "script-sample" => :sample
24
+ }.freeze
25
+
26
+ REPORTING_API_KEYS = {
27
+ "documentURL" => :document_uri, "blockedURL" => :blocked_uri,
28
+ "effectiveDirective" => :effective_directive, "disposition" => :disposition,
29
+ "sourceFile" => :source_file, "lineNumber" => :line_number,
30
+ "columnNumber" => :column_number, "sample" => :sample
31
+ }.freeze
32
+
33
+ validates :effective_directive, presence: true
34
+
35
+ scope :recent, -> { order(created_at: :desc, id: :desc) }
36
+ scope :within, ->(window) { where(created_at: window.ago..) }
37
+ scope :expired, -> { where(created_at: ...RETENTION.ago) }
38
+
39
+ # Records every violation in a raw report body; returns how many were kept.
40
+ # Raises JSON::ParserError on a body that isn't JSON.
41
+ def self.record_report!(raw, ip:, user_agent:)
42
+ violations = parse(raw).reject { |attrs| noise?(attrs) }
43
+ violations.each do |attrs|
44
+ create!(attrs.merge(ip:, user_agent: user_agent&.truncate(FIELD_LIMIT, omission: "")))
45
+ end
46
+ violations.size
47
+ end
48
+
49
+ def self.parse(raw)
50
+ payload = JSON.parse(raw)
51
+ reports = payload.is_a?(Array) ? payload : [payload]
52
+
53
+ reports.filter_map do |report|
54
+ next unless report.is_a?(Hash)
55
+
56
+ if report.key?("csp-report")
57
+ normalize(report["csp-report"], LEGACY_KEYS)
58
+ elsif report["type"] == "csp-violation"
59
+ normalize(report["body"], REPORTING_API_KEYS)
60
+ end
61
+ end
62
+ end
63
+
64
+ def self.noise?(attrs)
65
+ [attrs[:blocked_uri], attrs[:source_file]].compact.any? do |uri|
66
+ NOISE_SCHEMES.any? { |scheme| uri.start_with?("#{scheme}:") }
67
+ end
68
+ end
69
+
70
+ def self.normalize(body, keys)
71
+ return unless body.is_a?(Hash)
72
+
73
+ attrs = keys.each_with_object({}) do |(key, attribute), acc|
74
+ acc[attribute] ||= body[key] if body.key?(key)
75
+ end
76
+ # The directive alone (`script-src`), not the whole violated policy line.
77
+ attrs[:effective_directive] = attrs[:effective_directive].to_s.split.first
78
+ # URLs lose their query and fragment: a reported URL can carry a token.
79
+ %i[document_uri blocked_uri source_file].each { |key| attrs[key] = strip_url(attrs[key]) }
80
+ attrs[:sample] = attrs[:sample]&.to_s&.truncate(SAMPLE_LIMIT, omission: "")
81
+ %i[line_number column_number].each { |key| attrs[key] = attrs[key]&.to_i }
82
+ attrs.transform_values { |value| value.is_a?(String) ? value.truncate(FIELD_LIMIT, omission: "") : value }
83
+ end
84
+
85
+ def self.strip_url(value)
86
+ value&.to_s&.split(/[?#]/, 2)&.first
87
+ end
88
+ private_class_method :normalize, :strip_url
89
+ end
@@ -0,0 +1,30 @@
1
+ # What the Security page shows about Content Security Policy: the last `window`
2
+ # of reported violations, grouped by what was blocked, where and why.
3
+ class Security::CspViolationReport
4
+ Group = Data.define(:effective_directive, :blocked_uri, :document_uri, :count, :last_seen)
5
+
6
+ attr_reader :window, :limit
7
+
8
+ def initialize(window: 7.days, limit: 10)
9
+ @window = window
10
+ @limit = limit
11
+ end
12
+
13
+ def violations = Security::CspViolation.within(window)
14
+
15
+ def empty? = violations.none?
16
+
17
+ def total = violations.count
18
+
19
+ # The same blocked resource on the same page under the same directive, most
20
+ # frequent first — one row per thing to fix, however many readers hit it.
21
+ def top
22
+ violations.group(:effective_directive, :blocked_uri, :document_uri)
23
+ .order(Arel.sql("COUNT(*) DESC, MAX(created_at) DESC")).limit(limit)
24
+ .pluck(:effective_directive, :blocked_uri, :document_uri, Arel.sql("COUNT(*)"), Arel.sql("MAX(created_at)"))
25
+ .map do |directive, blocked, document, count, last|
26
+ Group.new(effective_directive: directive, blocked_uri: blocked, document_uri: document,
27
+ count:, last_seen: last.is_a?(String) ? ActiveSupport::TimeZone["UTC"].parse(last) : last)
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,42 @@
1
+ <%# locals: (report:) %>
2
+ <%# Content Security Policy violations browsers reported to /csp-reports
3
+ (maquina:csp_reports), grouped so each row is one thing to fix. %>
4
+ <%= render "components/card" do %>
5
+ <%= render "components/card/header" do %>
6
+ <%= render "components/card/title", text: "Content Security Policy violations" %>
7
+ <%= render "components/card/description",
8
+ text: "#{number_with_delimiter(report.total)} reported in the last #{report.window.inspect}, kept for #{Security::CspViolation::RETENTION.inspect}. Most frequent first." %>
9
+ <% end %>
10
+ <%= render "components/card/content", spacing: :full do %>
11
+ <% if report.empty? %>
12
+ <%= empty_state(title: "No violation was reported.", icon: :check_circle, size: :compact) %>
13
+ <% else %>
14
+ <div class="overflow-x-auto">
15
+ <%= render "components/table", container: false, "aria-label": "Content Security Policy violations" do %>
16
+ <%= render "components/table/header" do %>
17
+ <%= render "components/table/row" do %>
18
+ <%= render "components/table/head", text: "Directive" %>
19
+ <%= render "components/table/head", text: "Blocked" %>
20
+ <%= render "components/table/head", text: "Page" %>
21
+ <%= render "components/table/head", text: "Reports", css_classes: "text-right" %>
22
+ <%= render "components/table/head", text: "Last seen" %>
23
+ <% end %>
24
+ <% end %>
25
+ <%= render "components/table/body" do %>
26
+ <% report.top.each do |group| %>
27
+ <%= render "components/table/row" do %>
28
+ <%= render "components/table/cell", css_classes: "font-mono", text: group.effective_directive %>
29
+ <%= render "components/table/cell", css_classes: "font-mono whitespace-normal break-all", text: group.blocked_uri.presence || "—" %>
30
+ <%= render "components/table/cell", css_classes: "font-mono whitespace-normal break-all", text: group.document_uri.presence || "—" %>
31
+ <%= render "components/table/cell", css_classes: "text-right tabular-nums", text: number_with_delimiter(group.count) %>
32
+ <%= render "components/table/cell" do %>
33
+ <%= render "backstage/security/utc_time", time: group.last_seen %>
34
+ <% end %>
35
+ <% end %>
36
+ <% end %>
37
+ <% end %>
38
+ <% end %>
39
+ </div>
40
+ <% end %>
41
+ <% end %>
42
+ <% end %>
@@ -0,0 +1,23 @@
1
+ # Content Security Policy violation reports (maquina:csp_reports). Lives in the
2
+ # `security` database next to abuse_events; db/security_schema.rb carries the same
3
+ # table for a fresh database, this migration adds it to an existing one.
4
+ class CreateCspViolations < ActiveRecord::Migration<%= migration_version %>
5
+ def change
6
+ create_table :csp_violations do |t|
7
+ t.string :effective_directive, null: false
8
+ t.string :blocked_uri
9
+ t.string :document_uri
10
+ t.string :source_file
11
+ t.integer :line_number
12
+ t.integer :column_number
13
+ t.string :disposition
14
+ t.string :sample
15
+ t.string :ip
16
+ t.string :user_agent
17
+ t.datetime :created_at, null: false
18
+ end
19
+
20
+ add_index :csp_violations, :created_at
21
+ add_index :csp_violations, [:effective_directive, :created_at]
22
+ end
23
+ end
@@ -0,0 +1,18 @@
1
+ Description:
2
+ Adds passkey (WebAuthn) sign-in on top of Clave's email-code login. Needs
3
+ maquina:clave; the email code stays as the way in without a passkey.
4
+
5
+ Adds the webauthn gem, which only verifies the ceremonies; the flow is
6
+ generated into the app. Installs the Passkey model and a webauthn_id on
7
+ users, sign-in endpoints (POST /session/passkey_challenge and
8
+ /session/passkey) that ask for no email, a /passkeys page to add, rename
9
+ and remove passkeys (adding or removing needs a sign-in from the last 30
10
+ minutes), two Stimulus controllers that use the browser's own WebAuthn JSON
11
+ helpers (no JavaScript library), a passkey button and autofill on the
12
+ sign-in page, config/initializers/webauthn.rb, EN/ES locales, a
13
+ Rack::Attack throttle and a FakeClient test helper. Anything it cannot
14
+ place safely is listed after the install instead of being forced.
15
+
16
+ Examples:
17
+ rails g maquina:passkeys
18
+ rails g maquina:passkeys --skip-views # Endpoints only; bring your own UI
@@ -0,0 +1,259 @@
1
+ require "rails/generators"
2
+ require "rails/generators/migration"
3
+
4
+ module Maquina
5
+ module Generators
6
+ class PasskeysGenerator < Rails::Generators::Base
7
+ include Rails::Generators::Migration
8
+
9
+ source_root File.expand_path("templates", __dir__)
10
+
11
+ GEM_LINE = %(gem "webauthn", "~> 3.4")
12
+ USER_SESSIONS = /^ has_many :sessions, dependent: :destroy\n/
13
+ USER_PRIVATE = /^ private\n/
14
+ SIGN_IN_FORM_END = /^ <% end %>\n/
15
+ EMAIL_AUTOCOMPLETE = %(autocomplete: "email")
16
+ TEST_HELP = /^require "rails\/test_help"\n/
17
+ THROTTLE_LIMIT = 20
18
+ THROTTLE_PERIOD = "1.minute"
19
+
20
+ class_option :skip_views, type: :boolean, default: false,
21
+ desc: "Skip view templates and Stimulus controllers"
22
+ class_option :quiet, type: :boolean, default: false,
23
+ desc: "Suppress post-install instructions"
24
+
25
+ def self.next_migration_number(dirname)
26
+ current = Time.now.utc.strftime("%Y%m%d%H%M%S")
27
+ existing = Dir.glob("#{dirname}/[0-9]*_*.rb").map { |f| File.basename(f).split("_", 2).first }.max
28
+ (existing && existing >= current) ? existing.succ : current
29
+ end
30
+
31
+ # 1. Passkeys sign people in through clave's sessions; the email code
32
+ # stays as the way to get in without one.
33
+ def require_clave
34
+ return if %w[app/models/email_verification.rb app/controllers/concerns/authentication.rb]
35
+ .all? { |path| File.exist?(File.join(destination_root, path)) }
36
+
37
+ raise Thor::Error, "maquina:passkeys adds passkeys to Clave's email-code sign-in. Run `rails g maquina:clave` first."
38
+ end
39
+
40
+ # 2. The webauthn gem verifies the ceremonies; the flow stays in the app.
41
+ def add_gem
42
+ gemfile = File.join(destination_root, "Gemfile")
43
+ return unless File.exist?(gemfile)
44
+ return if File.read(gemfile).match?(/^\s*gem ["']webauthn["']/)
45
+
46
+ append_to_file "Gemfile", "\n#{GEM_LINE}\n"
47
+ end
48
+
49
+ def run_bundle_install
50
+ return unless rails_app?
51
+
52
+ Bundler.with_unbundled_env do
53
+ system("bundle install", chdir: destination_root)
54
+ end
55
+ end
56
+
57
+ # 3. Relying party
58
+ def create_initializer
59
+ copy_file "config/initializers/webauthn.rb", "config/initializers/webauthn.rb"
60
+ end
61
+
62
+ # 4. Models
63
+ def create_models
64
+ copy_file "app/models/passkey.rb", "app/models/passkey.rb"
65
+
66
+ path = "app/models/user.rb"
67
+ content = File.read(File.join(destination_root, path))
68
+ return if content.include?("has_many :passkeys")
69
+
70
+ if content.match?(USER_SESSIONS) && content.scan(USER_PRIVATE).one?
71
+ inject_into_file path, user_association, after: USER_SESSIONS
72
+ inject_into_file path, user_webauthn_id, before: USER_PRIVATE
73
+ else
74
+ @user_pending = true
75
+ end
76
+ end
77
+
78
+ # 5. Controllers
79
+ def create_controllers
80
+ copy_file "app/controllers/concerns/passkey_challenge.rb", "app/controllers/concerns/passkey_challenge.rb"
81
+ copy_file "app/controllers/concerns/passkey_reauthentication.rb", "app/controllers/concerns/passkey_reauthentication.rb"
82
+ copy_file "app/controllers/session/passkey_challenges_controller.rb", "app/controllers/session/passkey_challenges_controller.rb"
83
+ copy_file "app/controllers/session/passkeys_controller.rb", "app/controllers/session/passkeys_controller.rb"
84
+ copy_file "app/controllers/passkey_challenges_controller.rb", "app/controllers/passkey_challenges_controller.rb"
85
+ copy_file "app/controllers/passkeys_controller.rb", "app/controllers/passkeys_controller.rb"
86
+ end
87
+
88
+ # 6. Routes
89
+ def add_routes
90
+ return if File.read(File.join(destination_root, "config/routes.rb")).include?("generated by maquina:passkeys")
91
+
92
+ route <<~RUBY
93
+ # Passkeys (generated by maquina:passkeys)
94
+ resource :session, only: [] do
95
+ resource :passkey_challenge, only: :create, controller: "session/passkey_challenges"
96
+ resource :passkey, only: :create, controller: "session/passkeys"
97
+ end
98
+ resource :passkey_challenge, only: :create
99
+ resources :passkeys, only: [:index, :create, :update, :destroy]
100
+ RUBY
101
+ end
102
+
103
+ # 7. Migrations
104
+ def add_migrations
105
+ migration_template "migration_add_webauthn_id_to_users.rb.tt", "db/migrate/add_webauthn_id_to_users.rb"
106
+ migration_template "migration_create_passkeys.rb.tt", "db/migrate/create_passkeys.rb"
107
+ end
108
+
109
+ # 8. Views and Stimulus controllers
110
+ def create_views
111
+ return if options[:skip_views]
112
+
113
+ copy_file "app/views/passkeys/index.html.erb", "app/views/passkeys/index.html.erb"
114
+ copy_file "app/views/sessions/_passkey.html.erb", "app/views/sessions/_passkey.html.erb"
115
+ copy_file "app/javascript/controllers/passkey_sign_in_controller.js",
116
+ "app/javascript/controllers/passkey_sign_in_controller.js"
117
+ copy_file "app/javascript/controllers/passkey_registration_controller.js",
118
+ "app/javascript/controllers/passkey_registration_controller.js"
119
+
120
+ add_passkey_to_sign_in_page
121
+ end
122
+
123
+ # 9. Locale files
124
+ def create_locale_files
125
+ copy_file "config/locales/passkeys.en.yml", "config/locales/passkeys.en.yml"
126
+ copy_file "config/locales/passkeys.es.yml", "config/locales/passkeys.es.yml"
127
+ end
128
+
129
+ # 10. Test helper and the flow's integration test
130
+ def create_test_helper
131
+ copy_file "test/test_helpers/webauthn_test_helper.rb", "test/test_helpers/webauthn_test_helper.rb"
132
+ copy_file "test/integration/passkeys_test.rb", "test/integration/passkeys_test.rb"
133
+
134
+ path = File.join(destination_root, "test/test_helper.rb")
135
+ return unless File.exist?(path)
136
+
137
+ content = File.read(path)
138
+ return if content.include?(test_helper_require)
139
+ return @test_helper_pending = true unless content.match?(TEST_HELP)
140
+
141
+ inject_into_file "test/test_helper.rb", "#{test_helper_require}\n", after: TEST_HELP
142
+ end
143
+
144
+ # 11. Rack::Attack: the sign-in ceremony gets its own login-style throttle.
145
+ def configure_rack_attack
146
+ path = "config/initializers/rack_attack.rb"
147
+ full_path = File.join(destination_root, path)
148
+ return @rack_attack_pending = true unless File.exist?(full_path)
149
+ return if File.read(full_path).include?(%("passkey/ip"))
150
+
151
+ append_to_file path, passkey_throttle
152
+ end
153
+
154
+ # 12. Post-install message
155
+ def show_post_install
156
+ return if options[:quiet]
157
+
158
+ say ""
159
+ say "Passkeys have been installed!", :green
160
+ say ""
161
+ say "Next steps:", :yellow
162
+ say " 1. bin/rails db:migrate"
163
+ say " 2. Link to passkeys_path from your account or settings page"
164
+ say " 3. Visit /passkeys signed in, add a passkey, and sign in with it at /session/new"
165
+ say ""
166
+ say "Production: passkeys are bound to your domain. Set them before deploying:", :yellow
167
+ say " WEBAUTHN_RP_ID=example.com"
168
+ say " WEBAUTHN_ORIGINS=https://example.com"
169
+ say " (or webauthn: {rp_id:, origins:} in credentials; see config/initializers/webauthn.rb)"
170
+ say ""
171
+ if @user_pending
172
+ say "Add to app/models/user.rb:", :yellow
173
+ say user_association
174
+ say user_webauthn_id
175
+ end
176
+ if @sign_in_pending
177
+ say "Offer passkeys on the sign-in page (app/views/sessions/new.html.erb):", :yellow
178
+ say " - render the button after the email form: <%= render \"sessions/passkey\" %>"
179
+ say %( - let the email field suggest passkeys: autocomplete: "username webauthn")
180
+ say ""
181
+ end
182
+ if @test_helper_pending
183
+ say "Load the passkey test helper in test/test_helper.rb:", :yellow
184
+ say " #{test_helper_require}"
185
+ say ""
186
+ end
187
+ if @rack_attack_pending
188
+ say "Throttle passkey sign-in in config/initializers/rack_attack.rb:", :yellow
189
+ say passkey_throttle
190
+ say ""
191
+ end
192
+ say "Optional:", :yellow
193
+ say " - Serving more than one domain? Publish /.well-known/webauthn (Related Origin Requests)"
194
+ say " - Edit config/locales/passkeys.*.yml to customize messages"
195
+ say " - Integration tests: register_passkey and sign_in_with_passkey(user) (test/test_helpers/webauthn_test_helper.rb)"
196
+ say ""
197
+ end
198
+
199
+ private
200
+
201
+ def rails_app?
202
+ File.exist?(File.join(destination_root, "bin/rails"))
203
+ end
204
+
205
+ def migration_version
206
+ "[#{Rails::VERSION::MAJOR}.#{Rails::VERSION::MINOR}]"
207
+ end
208
+
209
+ # The button goes after clave's email form, and the email field offers
210
+ # saved passkeys in its autofill. A page that no longer has that shape is
211
+ # left alone and listed after the install.
212
+ def add_passkey_to_sign_in_page
213
+ path = "app/views/sessions/new.html.erb"
214
+ full_path = File.join(destination_root, path)
215
+ return @sign_in_pending = true unless File.exist?(full_path)
216
+
217
+ content = File.read(full_path)
218
+ return if content.include?(%(render "sessions/passkey"))
219
+ return @sign_in_pending = true unless content.scan(SIGN_IN_FORM_END).one? && content.include?(EMAIL_AUTOCOMPLETE)
220
+
221
+ inject_into_file path, %(\n <%= render "sessions/passkey" %>\n), after: SIGN_IN_FORM_END
222
+ gsub_file path, EMAIL_AUTOCOMPLETE, %(autocomplete: "username webauthn")
223
+ end
224
+
225
+ def test_helper_require
226
+ %(require_relative "test_helpers/webauthn_test_helper")
227
+ end
228
+
229
+ def user_association
230
+ " has_many :passkeys, dependent: :destroy\n"
231
+ end
232
+
233
+ def user_webauthn_id
234
+ <<~RUBY.gsub(/^(?=.)/, " ")
235
+ # The WebAuthn user handle: random and stable, never the id or the email,
236
+ # since authenticators store it and hand it back on every sign-in.
237
+ def webauthn_id
238
+ super || begin
239
+ update_column(:webauthn_id, WebAuthn.generate_user_id)
240
+ super
241
+ end
242
+ end
243
+
244
+ RUBY
245
+ end
246
+
247
+ def passkey_throttle
248
+ <<~RUBY
249
+
250
+ # Passkey sign-in (maquina:passkeys): the challenge and the assertion,
251
+ # counted like the email-code login above.
252
+ 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
+ end
255
+ RUBY
256
+ end
257
+ end
258
+ end
259
+ 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")
47
+ else
48
+ redirect_to passkeys_path, alert: @passkey.errors.full_messages.to_sentence
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