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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d8023146b756cacff577544a89e379d751c5f4f1e1df5fd1cb61932a514049bd
4
- data.tar.gz: 1bff652af72d0e15d9dbd66e90e758b8f900200d5ae5dbd5c783157cfaa49f3a
3
+ metadata.gz: 8127a7f3efbfaea2bb59f4c0b0977476fd7bc4d176d634d294d9520dd3a10f83
4
+ data.tar.gz: ef59f91d08b1f3bb99343f28564e5d4c07972201724e3c58a7ee37b1ab93c07f
5
5
  SHA512:
6
- metadata.gz: 882a19b2e1d69f38e9acc0e73a312bb5a9750155b7521c6a2dc9597ddb93c1934c83536d5525a74916b7750a49f3bfd94543bd09dcd01a43a2aa2d8b0525d794
7
- data.tar.gz: b9a6a548f3b6dbf7f6871e68588066f97bad710e2fc5a978dc9dcb32636b2448c60c86928dbc874b87cc4b3012030f94becc893049635418fb0df6e29e19b474
6
+ metadata.gz: 61d953a6567afb896d605dcf653bd8cd71d192fa81d043262157b32f915847f70e22bc74575cc35468f093b00532a0d910e39108fc6d0224ae7a6b5813fe832b
7
+ data.tar.gz: 7e65996ad9c0e7e61bbd8ce5774432df06a8176005741b68ba2c3757cbaffa42439e1bacae486b5966e4b3801470d8d506e5a6c019c2bb4d95e9615cb2beb7a2
data/README.md CHANGED
@@ -19,6 +19,8 @@ A collection of Rails generators from the Maquina umbrella. Each generator produ
19
19
  - **Migrations:** 4 migrations (accounts, users, sessions, email_verifications)
20
20
  - **Test helper:** `sign_in_as(user)` and `sign_out` for integration tests
21
21
 
22
+ Sign-in codes are encrypted at rest with Active Record encryption and compared in constant time. The generator adds the encryption keys to `config/credentials.yml.enc` when they are missing; without a master key, run `bin/rails db:encryption:init`.
23
+
22
24
  #### Installation
23
25
 
24
26
  Add to your Gemfile:
@@ -55,10 +57,64 @@ All generated code lives in your app -- edit it directly:
55
57
  - **Session duration:** Edit `authentication.rb` (default: 30 days)
56
58
  - **Code expiration:** Edit controllers (default: 15 minutes)
57
59
  - **Cooldown between codes:** Edit `EmailVerification::COOLDOWN_MINUTES` (default: 15)
60
+ - **Wrong guesses before a code locks:** Edit `EmailVerification::MAX_ATTEMPTS` (default: 5)
58
61
  - **Colors/styling:** Edit view templates (default: indigo)
59
62
  - **Email sender:** Edit `app/mailers/verification_mailer.rb`
60
63
  - **Translations:** Edit `config/locales/clave.*.yml`
61
64
 
65
+ ### Passkeys -- Passkey Sign-In for Clave
66
+
67
+ **Passkeys** adds WebAuthn passkeys as a second way to sign in, on top of Clave. People add passkeys from a `/passkeys` page and sign in with Face ID, a fingerprint or a device PIN, either with a button or straight from the email field's autofill. The email code stays as the way in without a passkey, so removing every passkey never locks anyone out.
68
+
69
+ The [`webauthn`](https://github.com/cedarcode/webauthn-ruby) gem handles only the cryptography (attestation and assertion parsing, signature, origin, challenge and user-verification checks). The flow itself, including controllers, model, views and JavaScript, is generated into your app. There is no JavaScript dependency: the Stimulus controllers use the browser's own `PublicKeyCredential.parseCreationOptionsFromJSON` / `parseRequestOptionsFromJSON` / `toJSON()`. The names (`passkeys` table, `credential_id`, `Session::PasskeysController`) follow the passkeys work proposed for Rails itself (rails/rails#57100).
70
+
71
+ #### What it generates
72
+
73
+ - **Gem:** `webauthn ~> 3.4`
74
+ - **Initializer:** `config/initializers/webauthn.rb`. The relying party is `localhost` in development; production reads `WEBAUTHN_RP_ID` / `WEBAUTHN_ORIGINS` (or `webauthn:` in credentials) and refuses to boot without them, except during the asset build.
75
+ - **Models:** `Passkey` (credential id, public key, sign count, transports, AAGUID, backup flags, last used) and `User#webauthn_id`, a random user handle created on first use
76
+ - **Sign-in:** `Session::PasskeyChallengesController` and `Session::PasskeysController`. These need no email (discoverable credentials, empty allow-list), require user verification, use each challenge once and expire it after 5 minutes, and sign in through Clave's `start_new_session_for`. An unknown credential gets a 404 so the browser can forget it (`signalUnknownCredential`).
77
+ - **Management:** `PasskeysController` and `PasskeyChallengesController` to add, rename and remove passkeys. Adding or removing a passkey needs a sign-in from the last 30 minutes (`PasskeyReauthentication::REAUTHENTICATE_AFTER`).
78
+ - **Views and JavaScript:** `passkeys/index`, a `sessions/_passkey` partial rendered on the sign-in page (and `autocomplete="username webauthn"` on its email field), plus `passkey_sign_in_controller.js` and `passkey_registration_controller.js`
79
+ - **Migrations:** `add_webauthn_id_to_users`, `create_passkeys`
80
+ - **Locale files:** English and Spanish
81
+ - **Rack::Attack:** a `passkey/ip` throttle when `config/initializers/rack_attack.rb` exists
82
+ - **Tests:** a `register_passkey` and `sign_in_with_passkey(user)` helper on top of `WebAuthn::FakeClient`, loaded from `test/test_helper.rb`, and `test/integration/passkeys_test.rb` covering the flow. The test creates users the way Clave's `User` does, so adjust its `setup` if your model differs.
83
+
84
+ #### Usage
85
+
86
+ ```bash
87
+ rails g maquina:clave # first
88
+ rails g maquina:passkeys
89
+ rails db:migrate
90
+ ```
91
+
92
+ Then link to `passkeys_path` from your account or settings page. Anything the generator cannot place safely (a `User` model or sign-in page that no longer has Clave's shape) is listed after the install instead of being forced.
93
+
94
+ #### Options
95
+
96
+ ```bash
97
+ rails g maquina:passkeys # Full install
98
+ rails g maquina:passkeys --skip-views # Endpoints only; bring your own UI
99
+ ```
100
+
101
+ #### Production
102
+
103
+ Passkeys are bound to a domain, and changing `rp_id` later orphans every saved passkey. Set it before the first user adds one:
104
+
105
+ ```bash
106
+ WEBAUTHN_RP_ID=example.com
107
+ WEBAUTHN_ORIGINS=https://example.com,https://app.example.com
108
+ ```
109
+
110
+ `rp_id` can be a parent of the origins' host. To serve passkeys across different domains, publish `/.well-known/webauthn` (Related Origin Requests).
111
+
112
+ #### Testing
113
+
114
+ - **Integration tests:** `register_passkey` and `sign_in_with_passkey(user)` go through the real endpoints with `WebAuthn::FakeClient`.
115
+ - **In a browser:** Chrome's virtual authenticator works in DevTools (More tools > WebAuthn) or over CDP (`WebAuthn.addVirtualAuthenticator` with `ctap2`, `internal`, a resident key and user verification). It answers the sign-in page's autofill request on its own, so loading `/session/new` signs you in without a click. To test the button, make `PublicKeyCredential.isConditionalMediationAvailable` return `false`.
116
+ - **Dev server on another port:** set `WEBAUTHN_ORIGINS=http://localhost:<port>`.
117
+
62
118
  ### Registration -- Password-Based Authentication with Accounts
63
119
 
64
120
  **Registration** generates a password-based authentication system with multi-tenant account support. It builds on top of the Rails 8 authentication generator, adding an Account model (tenant), user roles, and a registration flow.
@@ -322,6 +378,34 @@ Development's `:memory_store` keeps bans inside the server process, so they rese
322
378
 
323
379
  ---
324
380
 
381
+ ### CSP Reports -- Content Security Policy Violation Endpoint
382
+
383
+ **CSP Reports** gives the browser somewhere to send Content Security Policy violations and shows them on the Security page. It needs `maquina:security`, whose `security` database stores them.
384
+
385
+ #### What it generates
386
+
387
+ - **Controller:** `CspReportsController` at `POST /csp-reports` -- an API controller with no session or CSRF that accepts both the legacy `report-uri` body and the Reporting API body, capped at 64 KB. Reported URLs lose their query and fragment, and browser-extension violations are dropped
388
+ - **Models:** `Security::CspViolation` (30-day retention) and `Security::CspViolationReport` (the page's figures)
389
+ - **Database:** a migration in `db/security_migrate` and the `csp_violations` table in `db/security_schema.rb`
390
+ - **Retention:** a daily `purge_csp_violations` task in `config/recurring.yml`
391
+ - **Security page:** a section listing the last 7 days of violations, most frequent first
392
+ - **Policy:** points `report_uri` at the endpoint in `config/initializers/content_security_policy.rb` (no `report-to`, which switches Chrome to the batched Reporting API) and gives anonymous requests a nonce -- Rails' session-id nonce is empty without a session
393
+ - **Rack::Attack:** takes the endpoint out of the general throttle and caps it at 100 reports per 5 minutes per IP
394
+
395
+ Every step is idempotent. Anything it cannot place safely is listed after the install instead of being forced.
396
+
397
+ #### Usage
398
+
399
+ ```bash
400
+ rails g maquina:csp_reports
401
+ rails g maquina:csp_reports --path /security/csp-reports # Different endpoint path
402
+ bin/rails db:migrate
403
+ ```
404
+
405
+ Watch the Security page while the policy is report-only, then set `content_security_policy_report_only = false` to enforce it.
406
+
407
+ ---
408
+
325
409
  ### App -- Full Application Setup (Orchestrator)
326
410
 
327
411
  **App** is a meta-generator that sets up a complete Rails application in one command. Run it after `rails new myapp --css tailwind`.
@@ -1,5 +1,6 @@
1
1
  require "rails/generators"
2
2
  require "rails/generators/migration"
3
+ require "active_support/encrypted_configuration"
3
4
 
4
5
  module Maquina
5
6
  module Generators
@@ -42,6 +43,23 @@ module Maquina
42
43
  end
43
44
 
44
45
  # 2. Controllers
46
+ # EmailVerification encrypts its code, which needs Active Record
47
+ # encryption keys. They go into the app's credentials when missing; with
48
+ # no master key to write them, the post-install message says what to run.
49
+ def configure_encryption_keys
50
+ credentials = app_credentials
51
+ if credentials.nil?
52
+ @encryption_pending = true
53
+ return
54
+ end
55
+
56
+ content = credentials.read
57
+ return if content.match?(/^active_record_encryption:/)
58
+
59
+ credentials.write(content.rstrip + (content.strip.empty? ? "" : "\n\n") + encryption_keys_yaml)
60
+ @encryption_written = true
61
+ end
62
+
45
63
  def create_controllers
46
64
  template "app/controllers/concerns/authentication.rb.tt",
47
65
  "app/controllers/concerns/authentication.rb"
@@ -172,6 +190,15 @@ module Maquina
172
190
  say " 4. Visit /registration/new # Sign-up page"
173
191
  end
174
192
  say ""
193
+ if @encryption_written
194
+ say "Added Active Record encryption keys to config/credentials.yml.enc (EmailVerification encrypts its code).", :yellow
195
+ say ""
196
+ elsif @encryption_pending
197
+ say "EmailVerification encrypts its code. Generate keys and add them to your credentials:", :yellow
198
+ say " bin/rails db:encryption:init"
199
+ say " bin/rails credentials:edit"
200
+ say ""
201
+ end
175
202
  say "Optional:", :yellow
176
203
  say " - Edit app/controllers/concerns/authentication.rb to customize redirect URLs"
177
204
  say " - Edit config/locales/clave.*.yml to customize messages"
@@ -181,6 +208,27 @@ module Maquina
181
208
 
182
209
  private
183
210
 
211
+ # The app's credentials, or nil when there is no key to decrypt them with.
212
+ def app_credentials
213
+ key_path = File.join(destination_root, "config/master.key")
214
+ return unless File.exist?(key_path) || ENV["RAILS_MASTER_KEY"].present?
215
+
216
+ ActiveSupport::EncryptedConfiguration.new(
217
+ config_path: File.join(destination_root, "config/credentials.yml.enc"),
218
+ key_path: key_path, env_key: "RAILS_MASTER_KEY", raise_if_missing_key: false
219
+ )
220
+ end
221
+
222
+ # The same shape `bin/rails db:encryption:init` prints.
223
+ def encryption_keys_yaml
224
+ <<~YAML
225
+ active_record_encryption:
226
+ primary_key: #{SecureRandom.alphanumeric(32)}
227
+ deterministic_key: #{SecureRandom.alphanumeric(32)}
228
+ key_derivation_salt: #{SecureRandom.alphanumeric(32)}
229
+ YAML
230
+ end
231
+
184
232
  def migration_version
185
233
  "[#{Rails::VERSION::MAJOR}.#{Rails::VERSION::MINOR}]"
186
234
  end
@@ -26,7 +26,16 @@ class Registration::VerificationsController < ApplicationController
26
26
  return
27
27
  end
28
28
 
29
- if @verification.code != params[:code]&.upcase&.strip
29
+ # Too many wrong guesses: kill the code so a fresh one is required.
30
+ if @verification.locked?
31
+ @verification.update!(expires_at: Time.current)
32
+ Rails.logger.warn "[AUTH] Verification locked after #{@verification.attempts} attempts for #{@email} from #{request.remote_ip}"
33
+ flash.now[:alert] = t("flash.registrations.code_locked")
34
+ render :new, status: :unprocessable_entity
35
+ return
36
+ end
37
+
38
+ unless @verification.code_matches?(params[:code])
30
39
  @verification.increment!(:attempts)
31
40
  Rails.logger.warn "[AUTH] Invalid verification code attempt for #{@email} from #{request.remote_ip} (attempt #{@verification.attempts})"
32
41
  flash.now[:alert] = t("flash.registrations.invalid_code")
@@ -27,7 +27,16 @@ class Session::VerificationsController < ApplicationController
27
27
  return
28
28
  end
29
29
 
30
- if @verification.code != params[:code]&.upcase&.strip
30
+ # Too many wrong guesses: kill the code so a fresh one is required.
31
+ if @verification.locked?
32
+ @verification.update!(expires_at: Time.current)
33
+ Rails.logger.warn "[AUTH] Verification locked after #{@verification.attempts} attempts for #{@email} from #{request.remote_ip}"
34
+ flash.now[:alert] = t("flash.sessions.code_locked")
35
+ render :new, status: :unprocessable_entity
36
+ return
37
+ end
38
+
39
+ unless @verification.code_matches?(params[:code])
31
40
  @verification.increment!(:attempts)
32
41
  Rails.logger.warn "[AUTH] Invalid verification code attempt for #{@email} from #{request.remote_ip} (attempt #{@verification.attempts})"
33
42
  flash.now[:alert] = t("flash.sessions.invalid_code")
@@ -1,6 +1,7 @@
1
1
  class EmailVerification < ApplicationRecord
2
2
  VERIFICATION_TYPES = %w[signup login].freeze
3
3
  COOLDOWN_MINUTES = 15
4
+ MAX_ATTEMPTS = 5
4
5
 
5
6
  validates :email, presence: true, format: {with: URI::MailTo::EMAIL_REGEXP}
6
7
  validates :code, presence: true
@@ -10,6 +11,10 @@ class EmailVerification < ApplicationRecord
10
11
  normalizes :email, with: ->(email) { email.strip.downcase }
11
12
  normalizes :code, with: ->(code) { code.strip.upcase }
12
13
 
14
+ # Encrypted at rest, not digested: the mail goes out through deliver_later,
15
+ # whose job reloads this row by GlobalID and must read the code back.
16
+ encrypts :code
17
+
13
18
  scope :pending, -> { where(verified_at: nil) }
14
19
  scope :expired, -> { where("expires_at < ?", Time.current) }
15
20
  scope :active, -> { pending.where("expires_at >= ?", Time.current) }
@@ -20,6 +25,19 @@ class EmailVerification < ApplicationRecord
20
25
  expires_at < Time.current
21
26
  end
22
27
 
28
+ # A code is single-use against a bounded number of guesses: once the wrong-code
29
+ # count hits the ceiling the record is dead regardless of how many IPs an
30
+ # attacker rotates through, since each code only ever maps to one row.
31
+ def locked?
32
+ attempts >= MAX_ATTEMPTS
33
+ end
34
+
35
+ # Constant-time, so response timing reveals nothing about how close a guess
36
+ # came. Normalized the same way the stored code is.
37
+ def code_matches?(candidate)
38
+ ActiveSupport::SecurityUtils.secure_compare(code, candidate.to_s.strip.upcase)
39
+ end
40
+
23
41
  def verified?
24
42
  verified_at.present?
25
43
  end
@@ -30,11 +30,13 @@
30
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
31
  </div>
32
32
  <%% end %>
33
+ <% unless options[:skip_registration] -%>
33
34
 
34
35
  <p class="mt-6 text-center text-sm text-muted-foreground">
35
36
  <%%= t(".no_account") %>
36
37
  <%%= link_to t(".sign_up"), new_registration_path, class: "font-medium text-primary hover:text-primary/80" %>
37
38
  </p>
39
+ <% end -%>
38
40
  </div>
39
41
  </div>
40
42
  </div>
@@ -8,6 +8,7 @@ en:
8
8
  code_sent: "We sent you a verification code."
9
9
  invalid_code: "The code is incorrect. Please try again."
10
10
  code_expired: "The code has expired. Request a new one."
11
+ code_locked: "Too many incorrect tries. Request a new code to keep going."
11
12
  code_resent: "We sent you a new code."
12
13
  cooldown: "You must wait %{minutes} minutes before requesting another code."
13
14
  code_already_sent: "We already sent you a code. Check your email (including spam). You can request a new one in %{minutes} minutes."
@@ -19,6 +20,7 @@ en:
19
20
  email_taken: "An account with this email already exists. Would you like to sign in?"
20
21
  invalid_code: "The code is incorrect. Please try again."
21
22
  code_expired: "The code has expired. Request a new one."
23
+ code_locked: "Too many incorrect tries. Request a new code to keep going."
22
24
  code_resent: "We sent you a new code."
23
25
  cooldown: "You must wait %{minutes} minutes before requesting another code."
24
26
  code_already_sent: "We already sent you a code. Check your email (including spam). You can request a new one in %{minutes} minutes."
@@ -8,6 +8,7 @@ es:
8
8
  code_sent: "Te enviamos un codigo de verificacion."
9
9
  invalid_code: "El codigo es incorrecto. Por favor, intenta de nuevo."
10
10
  code_expired: "El codigo ha expirado. Solicita uno nuevo."
11
+ code_locked: "Demasiados intentos incorrectos. Solicita un codigo nuevo para continuar."
11
12
  code_resent: "Te enviamos un nuevo codigo."
12
13
  cooldown: "Debes esperar %{minutes} minutos antes de solicitar otro codigo."
13
14
  code_already_sent: "Ya te enviamos un codigo. Revisa tu correo (incluyendo spam). Podras solicitar uno nuevo en %{minutes} minutos."
@@ -19,6 +20,7 @@ es:
19
20
  email_taken: "Ya existe una cuenta con este correo. Quieres iniciar sesion?"
20
21
  invalid_code: "El codigo es incorrecto. Por favor, intenta de nuevo."
21
22
  code_expired: "El codigo ha expirado. Solicita uno nuevo."
23
+ code_locked: "Demasiados intentos incorrectos. Solicita un codigo nuevo para continuar."
22
24
  code_resent: "Te enviamos un nuevo codigo."
23
25
  cooldown: "Debes esperar %{minutes} minutos antes de solicitar otro codigo."
24
26
  code_already_sent: "Ya te enviamos un codigo. Revisa tu correo (incluyendo spam). Podras solicitar uno nuevo en %{minutes} minutos."
@@ -0,0 +1,18 @@
1
+ Description:
2
+ Adds a Content Security Policy violation report endpoint and shows the
3
+ reports on the backstage Security page. Needs maquina:security, whose
4
+ `security` database stores them.
5
+
6
+ Installs CspReportsController (POST <path>, no session or CSRF, both report
7
+ formats, 64 KB cap, browser-extension noise dropped), the
8
+ Security::CspViolation model and its migration in db/security_migrate, the
9
+ csp_violations table in db/security_schema.rb, a daily purge in
10
+ config/recurring.yml and a section on the Security page. It points the
11
+ policy's report-uri at the endpoint, gives anonymous requests a nonce
12
+ (Rails' session-id nonce is empty without a session), exempts the endpoint
13
+ from the general Rack::Attack throttle and caps it on its own. Anything it
14
+ cannot place safely is listed after the install instead of being forced.
15
+
16
+ Examples:
17
+ rails g maquina:csp_reports
18
+ rails g maquina:csp_reports --path /security/csp-reports
@@ -0,0 +1,231 @@
1
+ require "rails/generators"
2
+ require "rails/generators/migration"
3
+
4
+ module Maquina
5
+ module Generators
6
+ class CspReportsGenerator < Rails::Generators::Base
7
+ include Rails::Generators::Migration
8
+
9
+ source_root File.expand_path("templates", __dir__)
10
+
11
+ THROTTLE_LIMIT = 100
12
+ THROTTLE_PERIOD = "5.minutes"
13
+ # Rails' default nonce generator, empty for a request without a session.
14
+ SESSION_NONCE = "->(request) { request.session.id.to_s }"
15
+ POLICY_BLOCK = /^ config\.content_security_policy do \|policy\|\n/
16
+ # The general throttle's exemptions, in the two shapes maquina:rack_attack
17
+ # has shipped: a start_with? list, and a STATIC_FILE_PREFIXES word array.
18
+ COUNTED_IP_EXEMPTIONS = /req\.path\.start_with\?\("\/assets", "\/rails\/active_storage"/
19
+ STATIC_FILE_PREFIXES = /(STATIC_FILE_PREFIXES = %w\[)([^\]]*)(\])/
20
+ SECURITY_PAGE_END = /\n<\/div>\n\z/
21
+
22
+ class_option :path, type: :string, default: "/csp-reports",
23
+ desc: "Path browsers post violation reports to"
24
+ class_option :quiet, type: :boolean, default: false,
25
+ desc: "Suppress post-install instructions"
26
+
27
+ def self.next_migration_number(dirname)
28
+ current = Time.now.utc.strftime("%Y%m%d%H%M%S")
29
+ existing = Dir.glob("#{dirname}/[0-9]*_*.rb").map { |f| File.basename(f).split("_", 2).first }.max
30
+ (existing && existing >= current) ? existing.succ : current
31
+ end
32
+
33
+ # 1. maquina:security owns the database the reports go in.
34
+ def require_security_database
35
+ return if File.exist?(File.join(destination_root, "db/security_schema.rb"))
36
+
37
+ raise Thor::Error, "maquina:csp_reports stores reports in the `security` database. Run `rails g maquina:security --prefix /admin` first."
38
+ end
39
+
40
+ # 2. Models
41
+ def create_models
42
+ copy_file "app/models/security/csp_violation.rb", "app/models/security/csp_violation.rb"
43
+ copy_file "app/models/security/csp_violation_report.rb", "app/models/security/csp_violation_report.rb"
44
+ end
45
+
46
+ # 3. Controller and route
47
+ def create_controller
48
+ copy_file "app/controllers/csp_reports_controller.rb", "app/controllers/csp_reports_controller.rb"
49
+ route %(post "#{options[:path]}", to: "csp_reports#create", as: :csp_reports)
50
+ end
51
+
52
+ # 4. The table: a migration for an existing security database, the schema
53
+ # for a fresh one (db:prepare loads the schema only on create).
54
+ def create_table
55
+ return if File.read(schema_path).include?(%("csp_violations"))
56
+
57
+ migration_template "db/create_csp_violations.rb.tt", "db/security_migrate/create_csp_violations.rb"
58
+ version = Dir.glob(File.join(destination_root, "db/security_migrate/*_create_csp_violations.rb"))
59
+ .map { |f| File.basename(f).split("_", 2).first }.max
60
+ gsub_file "db/security_schema.rb", /define\(version: [\d_]+\)/, "define(version: #{version_with_underscores(version)})"
61
+ inject_into_file "db/security_schema.rb", csp_violations_table, before: /^end\s*\z/
62
+ end
63
+
64
+ # 5. The policy reports to the endpoint, and anonymous requests get a nonce.
65
+ def configure_content_security_policy
66
+ path = "config/initializers/content_security_policy.rb"
67
+ content = File.exist?(File.join(destination_root, path)) ? File.read(File.join(destination_root, path)) : ""
68
+
69
+ if content.include?("report_uri")
70
+ @report_uri_pending = !content.include?(%(report_uri "#{options[:path]}"))
71
+ elsif content.match?(POLICY_BLOCK)
72
+ inject_into_file path, report_uri_line, after: POLICY_BLOCK
73
+ else
74
+ @report_uri_pending = true
75
+ end
76
+
77
+ if content.include?(SESSION_NONCE)
78
+ gsub_file path, SESSION_NONCE, "->(request) { request.session.id.to_s.presence || SecureRandom.base64(16) }"
79
+ end
80
+ end
81
+
82
+ # 6. Rack::Attack: out of the general throttle, capped on its own.
83
+ def configure_rack_attack
84
+ path = "config/initializers/rack_attack.rb"
85
+ full_path = File.join(destination_root, path)
86
+ return @rack_attack_pending = true unless File.exist?(full_path)
87
+
88
+ content = File.read(full_path)
89
+ return if content.include?(%("csp/ip"))
90
+
91
+ if content.match?(COUNTED_IP_EXEMPTIONS)
92
+ # The match stops before the call's closing paren, which stays in place.
93
+ gsub_file path, COUNTED_IP_EXEMPTIONS, 'req.path.start_with?("/assets", "/rails/active_storage", ' + %("#{options[:path]}")
94
+ elsif content.match?(STATIC_FILE_PREFIXES)
95
+ gsub_file path, STATIC_FILE_PREFIXES, "\\1\\2 #{options[:path]}\\3"
96
+ else
97
+ @rack_attack_pending = true
98
+ end
99
+
100
+ append_to_file path, csp_throttle
101
+ end
102
+
103
+ # 7. Daily purge
104
+ def add_recurring_purge
105
+ path = File.join(destination_root, "config/recurring.yml")
106
+ content = File.exist?(path) ? File.read(path) : ""
107
+ return if content.include?("purge_csp_violations")
108
+
109
+ if content.match?(/^production:\n/)
110
+ inject_into_file "config/recurring.yml", recurring_entry, after: /^production:\n/
111
+ else
112
+ @recurring_pending = true
113
+ end
114
+ end
115
+
116
+ # 8. Security page section
117
+ def add_security_page_section
118
+ copy_file "app/views/backstage/security/_csp_violations.html.erb",
119
+ "app/views/backstage/security/_csp_violations.html.erb"
120
+
121
+ page = "app/views/backstage/security/show.html.erb"
122
+ full_path = File.join(destination_root, page)
123
+ content = File.exist?(full_path) ? File.read(full_path) : ""
124
+ return if content.include?("backstage/security/csp_violations")
125
+
126
+ if content.match?(SECURITY_PAGE_END)
127
+ inject_into_file page, security_page_section, before: SECURITY_PAGE_END
128
+ else
129
+ @page_pending = true
130
+ end
131
+ end
132
+
133
+ # 9. Post-install message
134
+ def show_post_install
135
+ return if options[:quiet]
136
+
137
+ say ""
138
+ say "Content Security Policy reports are set up at POST #{options[:path]}.", :green
139
+ say ""
140
+ say "Next steps:", :yellow
141
+ say " - Add the table: bin/rails db:migrate (db:prepare on deploy)"
142
+ say " - Watch the Security page while the policy is report-only, then set"
143
+ say " content_security_policy_report_only = false to enforce it."
144
+ if @report_uri_pending
145
+ say ""
146
+ say "Point the policy at the endpoint in config/initializers/content_security_policy.rb:", :yellow
147
+ say " #{report_uri_line.strip}"
148
+ end
149
+ if @rack_attack_pending
150
+ say ""
151
+ say "Exempt #{options[:path]} from the general throttle in config/initializers/rack_attack.rb and add:", :yellow
152
+ say csp_throttle
153
+ end
154
+ if @recurring_pending
155
+ say ""
156
+ say "Add to config/recurring.yml under your production (or shared) key:", :yellow
157
+ say recurring_entry
158
+ end
159
+ if @page_pending
160
+ say ""
161
+ say "Render the section on the Security page:", :yellow
162
+ say security_page_section
163
+ end
164
+ say ""
165
+ end
166
+
167
+ private
168
+
169
+ def schema_path = File.join(destination_root, "db/security_schema.rb")
170
+
171
+ def migration_version
172
+ "[#{Rails::VERSION::MAJOR}.#{Rails::VERSION::MINOR}]"
173
+ end
174
+
175
+ def version_with_underscores(version)
176
+ version.sub(/\A(\d{4})(\d{2})(\d{2})(\d{6})\z/, '\1_\2_\3_\4')
177
+ end
178
+
179
+ def report_uri_line
180
+ %( # Violations go to CspReportsController (maquina:csp_reports). report-uri\n) +
181
+ %( # alone: adding report-to switches Chrome to the batched Reporting API.\n) +
182
+ %( policy.report_uri "#{options[:path]}"\n)
183
+ end
184
+
185
+ def csp_throttle
186
+ <<~RUBY
187
+
188
+ # Content Security Policy reports (maquina:csp_reports): exempt from the
189
+ # general throttle above — one page with a dozen violations would spend a
190
+ # reader's budget — and capped on their own.
191
+ Rack::Attack.throttle("csp/ip", limit: #{THROTTLE_LIMIT}, period: #{THROTTLE_PERIOD}) do |req|
192
+ (req.env["action_dispatch.remote_ip"]&.to_s || req.ip) if req.path == "#{options[:path]}" && req.post?
193
+ end
194
+ RUBY
195
+ end
196
+
197
+ def recurring_entry
198
+ <<~YAML.gsub(/^/, " ")
199
+ purge_csp_violations:
200
+ command: "Security::CspViolation.expired.delete_all"
201
+ schedule: every day at 4:30am
202
+ YAML
203
+ end
204
+
205
+ def security_page_section
206
+ %(\n\n <%= render "backstage/security/csp_violations", report: Security::CspViolationReport.new %>)
207
+ end
208
+
209
+ def csp_violations_table
210
+ <<~RUBY.gsub(/^(?=.)/, " ")
211
+
212
+ create_table "csp_violations", force: :cascade do |t|
213
+ t.integer "column_number"
214
+ t.datetime "created_at", null: false
215
+ t.string "blocked_uri"
216
+ t.string "disposition"
217
+ t.string "document_uri"
218
+ t.string "effective_directive", null: false
219
+ t.string "ip"
220
+ t.integer "line_number"
221
+ t.string "sample"
222
+ t.string "source_file"
223
+ t.string "user_agent"
224
+ t.index ["created_at"], name: "index_csp_violations_on_created_at"
225
+ t.index ["effective_directive", "created_at"], name: "index_csp_violations_on_effective_directive_and_created_at"
226
+ end
227
+ RUBY
228
+ end
229
+ end
230
+ end
231
+ end
@@ -0,0 +1,24 @@
1
+ # Receives Content Security Policy violation reports. Browsers post them with no
2
+ # cookie and no CSRF token, in either the legacy report-uri shape
3
+ # (application/csp-report) or the Reporting API's (application/reports+json),
4
+ # so this is an API controller that reads the raw body itself.
5
+ #
6
+ # Always 204 for a report it could read — a browser does nothing with the
7
+ # answer — and best effort: a locked security database must never turn a
8
+ # report into a 500 that the browser retries.
9
+ class CspReportsController < ActionController::API
10
+ MAX_BYTES = 64.kilobytes
11
+
12
+ def create
13
+ body = request.body.read(MAX_BYTES + 1).to_s
14
+ return head 413 if body.bytesize > MAX_BYTES
15
+
16
+ Security::CspViolation.record_report!(body, ip: request.remote_ip, user_agent: request.user_agent)
17
+ head :no_content
18
+ rescue JSON::ParserError
19
+ head :bad_request
20
+ rescue => error
21
+ Rails.logger.error "[CSP] report not recorded: #{error.class}: #{error.message}"
22
+ head :no_content
23
+ end
24
+ end