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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8127a7f3efbfaea2bb59f4c0b0977476fd7bc4d176d634d294d9520dd3a10f83
|
|
4
|
+
data.tar.gz: ef59f91d08b1f3bb99343f28564e5d4c07972201724e3c58a7ee37b1ab93c07f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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")
|
data/lib/generators/maquina/clave/templates/app/controllers/session/verifications_controller.rb.tt
CHANGED
|
@@ -27,7 +27,16 @@ class Session::VerificationsController < ApplicationController
|
|
|
27
27
|
return
|
|
28
28
|
end
|
|
29
29
|
|
|
30
|
-
|
|
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
|