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.
- checksums.yaml +4 -4
- data/README.md +84 -0
- data/lib/generators/maquina/clave/clave_generator.rb +48 -0
- data/lib/generators/maquina/clave/templates/app/controllers/registration/verifications_controller.rb.tt +10 -1
- data/lib/generators/maquina/clave/templates/app/controllers/session/verifications_controller.rb.tt +10 -1
- data/lib/generators/maquina/clave/templates/app/models/email_verification.rb.tt +18 -0
- data/lib/generators/maquina/clave/templates/app/views/sessions/new.html.erb.tt +2 -0
- data/lib/generators/maquina/clave/templates/config/locales/clave.en.yml +2 -0
- data/lib/generators/maquina/clave/templates/config/locales/clave.es.yml +2 -0
- data/lib/generators/maquina/csp_reports/USAGE +18 -0
- data/lib/generators/maquina/csp_reports/csp_reports_generator.rb +231 -0
- data/lib/generators/maquina/csp_reports/templates/app/controllers/csp_reports_controller.rb +24 -0
- data/lib/generators/maquina/csp_reports/templates/app/models/security/csp_violation.rb +89 -0
- data/lib/generators/maquina/csp_reports/templates/app/models/security/csp_violation_report.rb +30 -0
- data/lib/generators/maquina/csp_reports/templates/app/views/backstage/security/_csp_violations.html.erb +42 -0
- data/lib/generators/maquina/csp_reports/templates/db/create_csp_violations.rb.tt +23 -0
- data/lib/generators/maquina/passkeys/USAGE +18 -0
- data/lib/generators/maquina/passkeys/passkeys_generator.rb +259 -0
- data/lib/generators/maquina/passkeys/templates/app/controllers/concerns/passkey_challenge.rb +42 -0
- data/lib/generators/maquina/passkeys/templates/app/controllers/concerns/passkey_reauthentication.rb +26 -0
- data/lib/generators/maquina/passkeys/templates/app/controllers/passkey_challenges_controller.rb +24 -0
- data/lib/generators/maquina/passkeys/templates/app/controllers/passkeys_controller.rb +68 -0
- data/lib/generators/maquina/passkeys/templates/app/controllers/session/passkey_challenges_controller.rb +18 -0
- data/lib/generators/maquina/passkeys/templates/app/controllers/session/passkeys_controller.rb +55 -0
- data/lib/generators/maquina/passkeys/templates/app/javascript/controllers/passkey_registration_controller.js +109 -0
- data/lib/generators/maquina/passkeys/templates/app/javascript/controllers/passkey_sign_in_controller.js +113 -0
- data/lib/generators/maquina/passkeys/templates/app/models/passkey.rb +36 -0
- data/lib/generators/maquina/passkeys/templates/app/views/passkeys/index.html.erb +77 -0
- data/lib/generators/maquina/passkeys/templates/app/views/sessions/_passkey.html.erb +23 -0
- data/lib/generators/maquina/passkeys/templates/config/initializers/webauthn.rb +23 -0
- data/lib/generators/maquina/passkeys/templates/config/locales/passkeys.en.yml +46 -0
- data/lib/generators/maquina/passkeys/templates/config/locales/passkeys.es.yml +46 -0
- data/lib/generators/maquina/passkeys/templates/migration_add_webauthn_id_to_users.rb.tt +6 -0
- data/lib/generators/maquina/passkeys/templates/migration_create_passkeys.rb.tt +20 -0
- data/lib/generators/maquina/passkeys/templates/test/integration/passkeys_test.rb +99 -0
- data/lib/generators/maquina/passkeys/templates/test/test_helpers/webauthn_test_helper.rb +38 -0
- data/lib/maquina_generators/version.rb +1 -1
- data/test/generators/maquina/clave_generator_test.rb +87 -2
- data/test/generators/maquina/csp_reports_generator_test.rb +151 -0
- data/test/generators/maquina/passkeys_generator_test.rb +253 -0
- data/test/tmp/Gemfile +1 -2
- data/test/tmp/app/controllers/application_controller.rb +1 -0
- data/test/tmp/app/controllers/concerns/authentication.rb +63 -0
- data/test/tmp/app/controllers/registration/verification_resends_controller.rb +38 -0
- data/test/tmp/app/controllers/registration/verifications_controller.rb +64 -0
- data/test/tmp/app/controllers/registrations_controller.rb +47 -21
- data/test/tmp/app/controllers/session/verification_resends_controller.rb +44 -0
- data/test/tmp/app/controllers/session/verifications_controller.rb +64 -0
- data/test/tmp/app/controllers/sessions_controller.rb +56 -0
- data/test/tmp/app/helpers/authentication_helper.rb +20 -0
- data/test/tmp/app/jobs/authentication_cleanup_job.rb +13 -0
- data/test/tmp/app/mailers/verification_mailer.rb +15 -0
- data/test/tmp/app/models/email_verification.rb +58 -0
- data/test/tmp/app/models/session.rb +18 -0
- data/test/tmp/app/models/user.rb +28 -3
- data/test/tmp/config/locales/clave.en.yml +102 -0
- data/test/tmp/config/locales/clave.es.yml +102 -0
- data/test/tmp/config/routes.rb +10 -2
- data/test/tmp/db/migrate/20261014212614_create_users.rb +17 -0
- data/test/tmp/db/migrate/20261014212615_create_sessions.rb +12 -0
- data/test/tmp/db/migrate/20261014212616_create_email_verifications.rb +17 -0
- data/test/tmp/test/test_helpers/session_test_helper.rb +19 -0
- metadata +52 -10
- data/test/tmp/app/views/registrations/new.html.erb +0 -72
- data/test/tmp/app/views/sessions/new.html.erb +0 -46
- data/test/tmp/config/locales/registration.en.yml +0 -44
- data/test/tmp/config/locales/registration.es.yml +0 -44
- data/test/tmp/db/migrate/20260927024164_add_account_fields_to_users.rb +0 -7
- /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
|
data/lib/generators/maquina/passkeys/templates/app/controllers/concerns/passkey_reauthentication.rb
ADDED
|
@@ -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
|
data/lib/generators/maquina/passkeys/templates/app/controllers/passkey_challenges_controller.rb
ADDED
|
@@ -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
|