concerns_on_rails 1.16.0 → 1.17.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: dbe5bebe66a84e336f6dbffff99b12d12ed65f6238ea000b9a149908b1a78e0c
4
- data.tar.gz: 8478287a2750b501288e02e30394cfb79a45e2208190c14e8fce945457229e2b
3
+ metadata.gz: 5759f8cf9f86e11087369181f53f807875909047c17fe8abb19a5b6bafaae697
4
+ data.tar.gz: d6e34235431b2c54a36de7126b4829b9892b05f66f0fc6b476ff561731452100
5
5
  SHA512:
6
- metadata.gz: 06bd9e4b6a3493047ea48b60f2ff75517bb3d117c8cbe4712071f7464db173c8cb73f7a3763d22a4376f458ba1d85deffca47648544ec4879642d14dc40e6a78
7
- data.tar.gz: 042252611fb6ef6a41c671d3cf2a17e29ee7dcf82349f8f84b22d04274ebbcd2c48c23a2b6a03291325e0d072daedf29981b84bc2fb0a11a6cf832d5f79a572e
6
+ metadata.gz: 2b101b659f6d47f0adcface39ef7069a978d8342e6499661d358af12037c12fb1b7d317cdd075ca9b5593989df2941e121ecf46e02d51c11e4f445cdf91801dd
7
+ data.tar.gz: 984342a4be9bae7692c880bb2436de85cd86a3a0e8cd4ba1c167168db297bf4a0ce42a3a7025de910b7e33495d67e8352667ea302b8d47e12c1824cd7bd4ae31
data/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  <!-- CHANGELOG.md -->
2
2
 
3
+ ## 1.17.0 (2026-06-10)
4
+
5
+ Two new concerns, hardened by two adversarial review rounds (in-memory state is restored when a raising lock/unlock hook rolls the write back, so retries can't silently no-op; a hook aborting via `ActiveRecord::Rollback` makes `lock_access!`/`unlock_access!` return false instead of a fake success; invalid-UTF-8 signature headers fail closed instead of raising). 679 examples, 0 failures.
6
+
7
+ ### Added
8
+ - **Models::Lockable**: failed-attempt tracking + account lockout ("Devise lockable-lite") for apps on Rails 8 native auth / `has_secure_password`. `lockable_by attempts: :failed_attempts, locked_at: :locked_at, max_attempts: 5, unlock_in: 15.minutes, prefix:/suffix:` — `register_failed_attempt!` increments SQL-side (`update_counters`: atomic under concurrency, NULL-coalescing) and auto-locks at the threshold; `access_locked?` / `lock_expired?` / `lock_expires_at` / `attempts_remaining` readers are side-effect free with lazy expiry (the boundary instant counts as unlocked); `lock_access!` / `unlock_access!` persist via `update_columns` (validations/callbacks bypassed so an invalid record can still be locked) with `before/after_lock`, `before/after_unlock` hooks in a transaction; `reset_failed_attempts!` is the hook-free successful-login path; expiry-aware `.locked` / `.unlocked` scopes (affixable, Ruby-computed cutoff so the SQL stays portable). An expired lock is cleared quietly on the next failed attempt — no unlock hooks fire from an attacker's guess. The attempts column is validated to be an integer column. Zero new runtime dependencies.
9
+ - **Controllers::WebhookVerifiable**: HMAC signature verification for inbound webhooks (Stripe/GitHub/Shopify and generic `:hex`/`:base64`). `verify_webhook *actions, secret:, scheme:, header:, tolerance:, digest:` appends rules (first match wins; no actions = catch-all); the `verify_webhook_signature!` before_action (public, skip-able) renders 401 (`webhook_signature_missing`/`webhook_signature_invalid`/`webhook_timestamp_stale`) or 400 (`webhook_signature_malformed`) and halts the action. Comparison is constant-time (digest-collapsed `secure_compare`, portable to Rails 5.0) and the attacker-controlled header is never decoded — garbage including invalid UTF-8 is scrubbed and fails closed instead of raising. Stripe: signs `"#{t}.#{body}"`, tries every `v1` (≤16), ignores unknown keys, first `t` feeds both the symmetric tolerance check (default 300s) and the payload so a re-stamped stale header stays dead. Secrets: String / callable (`instance_exec`'d per request, multi-tenant) / Array (rotation, any match passes); a secret resolving blank at request time raises `ArgumentError` (misconfiguration should page, not 401 into the provider's retry loop). `:shopify`/`:base64` encode via `pack("m0")` (no base64-gem dependency on Ruby 3.4). Delegates error bodies to `render_error` when Respondable is present. Zero new runtime dependencies.
10
+
3
11
  ## 1.16.0 (2026-06-10)
4
12
 
5
13
  Two new concerns, hardened by an adversarial edge-case review. 574 examples, 0 failures.
data/README.md CHANGED
@@ -44,6 +44,7 @@ Article.published.without_deleted.find("hello-world")
44
44
  - [Maskable](#-maskable) — non-destructive display masking of sensitive fields
45
45
  - [Monetizable](#-monetizable) — integer-cents money columns (BigDecimal)
46
46
  - [Auditable](#-auditable) — single-column change history ("paper_trail-lite")
47
+ - [Lockable](#-lockable) — failed-attempt tracking + account lockout
47
48
  - **Controller concerns**
48
49
  - [Paginatable](#-paginatable) — offset pagination with headers
49
50
  - [Filterable](#-filterable) — declarative URL-param filters
@@ -57,6 +58,7 @@ Article.published.without_deleted.find("hello-world")
57
58
  - [Throttleable](#-throttleable) — rate limiting with 429 + `X-RateLimit-*` headers
58
59
  - [Timezoneable](#-timezoneable) — per-request `Time.zone` from params / header / cookie
59
60
  - [Idempotentable](#-idempotentable) — `Idempotency-Key` request replay (409 on concurrent duplicates)
61
+ - [WebhookVerifiable](#-webhookverifiable) — HMAC verification for inbound webhooks (Stripe/GitHub/Shopify)
60
62
  - [Module paths & namespacing](#-module-paths--namespacing)
61
63
  - [Development](#-development)
62
64
  - [Contributing](#-contributing)
@@ -66,7 +68,7 @@ Article.published.without_deleted.find("hello-world")
66
68
 
67
69
  ## ✨ Why this gem?
68
70
 
69
- - **Nineteen model concerns + twelve controller concerns**, all production-ready
71
+ - **Twenty model concerns + thirteen controller concerns**, all production-ready
70
72
  - **One include, one macro** — no boilerplate, no glue code
71
73
  - **Lean dependencies** — only `acts_as_list` (Sortable) and `friendly_id` (Sluggable); controller concerns have zero extra deps
72
74
  - **Schema-validated configuration** — every macro checks that the configured column exists and raises `ArgumentError` early
@@ -970,6 +972,38 @@ One entry is recorded **per changed field per save** (creates record `"from" =>
970
972
 
971
973
  ---
972
974
 
975
+ ## 🔐 Lockable
976
+
977
+ Failed-attempt tracking + **account lockout** ("Devise lockable-lite") for apps rolling their own authentication (Rails 8 auth generator / `has_secure_password`) — which ships **no brute-force protection** out of the box. Two columns on the model's own table; no tokens, no mailers.
978
+
979
+ ```ruby
980
+ class User < ApplicationRecord
981
+ include ConcernsOnRails::Lockable
982
+
983
+ lockable_by max_attempts: 5, unlock_in: 15.minutes
984
+ # lockable_by attempts: :failed_logins, locked_at: :locked_until_at,
985
+ # prefix: :account # => .account_locked / .account_unlocked
986
+ end
987
+
988
+ user.register_failed_attempt! # atomic SQL increment; locks at max_attempts
989
+ user.access_locked? # true while locked (lapses after unlock_in)
990
+ user.attempts_remaining # => 3 (for "3 attempts remaining" messaging)
991
+ user.reset_failed_attempts! # call on successful login
992
+ user.lock_access! # manual lock (hooks: before/after_lock)
993
+ user.unlock_access! # manual unlock (hooks: before/after_unlock)
994
+ User.locked / User.unlocked # expiry-aware scopes
995
+ ```
996
+
997
+ **Options**: `attempts:` (`:failed_attempts`, must be an integer column), `locked_at:` (`:locked_at`, datetime column), `max_attempts:` (`5`; `nil` = count but never auto-lock), `unlock_in:` (`nil` = locked until manual unlock; a duration makes the lock lapse by itself), `prefix:` / `suffix:` (affix the scope names).
998
+
999
+ **Notes**
1000
+ - The increment is SQL-side (`COALESCE(attempts, 0) + 1` via `update_counters`), so concurrent failures never lose updates and a NULL counter needs no column default; a locked account stops counting.
1001
+ - Expiry is **lazy**: readers and scopes treat a stale lock as unlocked but never write. The column is cleared by the next `unlock_access!` or failed attempt (quietly there — no unlock hooks fire from a failed login).
1002
+ - `lock_access!` / `unlock_access!` persist via `update_columns` — validations and AR callbacks deliberately bypassed so an otherwise-invalid record can still be locked (this also skips `updated_at`/`Auditable`). The `before/after_lock`, `before/after_unlock` hooks run in a transaction; `after_lock` is the place for the "account locked" email.
1003
+ - Reach for Devise's `lockable` when you need unlock tokens, unlock emails, or per-strategy unlocks.
1004
+
1005
+ ---
1006
+
973
1007
  # 🎮 Controller Concerns
974
1008
 
975
1009
  Pure ActionController + ActiveRecord — **zero extra runtime dependencies** (no Kaminari, Pundit, or Ransack).
@@ -1361,6 +1395,45 @@ Per-key lifecycle: claim atomically (`write unless_exist`, TTL `lock_ttl:`) →
1361
1395
 
1362
1396
  ---
1363
1397
 
1398
+ ## 🪝 WebhookVerifiable
1399
+
1400
+ HMAC **signature verification for inbound webhooks** — the receiving side of Stripe/GitHub/Shopify-style integrations. The action runs only when the signature over the **raw request body** verifies; otherwise a 401/400 is rendered and the action never executes.
1401
+
1402
+ ```ruby
1403
+ class WebhooksController < ApplicationController
1404
+ include ConcernsOnRails::Controllers::WebhookVerifiable # declare BEFORE Idempotentable
1405
+
1406
+ verify_webhook :stripe, secret: -> { ENV["STRIPE_WEBHOOK_SECRET"] }, scheme: :stripe
1407
+ verify_webhook :github, secret: -> { ENV["GITHUB_WEBHOOK_SECRET"] }, scheme: :github
1408
+ verify_webhook :shopify, secret: [ENV["NEW_SECRET"], ENV["OLD_SECRET"]], scheme: :shopify # rotation
1409
+ verify_webhook :custom, secret: "s3cr3t", scheme: :hex, header: "X-Acme-Signature"
1410
+ # verify_webhook secret: ... # no actions = catch-all (declare specific rules first)
1411
+
1412
+ def stripe
1413
+ event = JSON.parse(request.raw_post) # parse the raw body — it is what was signed
1414
+ # ...
1415
+ end
1416
+ end
1417
+ ```
1418
+
1419
+ | Scheme | Header (default) | Format |
1420
+ |--------|------------------|--------|
1421
+ | `:github` | `X-Hub-Signature-256` | `sha256=<hex>` |
1422
+ | `:shopify` | `X-Shopify-Hmac-Sha256` | strict Base64 of the binary HMAC |
1423
+ | `:stripe` | `Stripe-Signature` | `t=<unix>,v1=<hex>[,v1=…]` — signs `"#{t}.#{body}"`, every `v1` tried, `tolerance:` rejects stale **and** future timestamps |
1424
+ | `:hex` / `:base64` | — (`header:` required) | plain hex / strict Base64 HMAC of the body |
1425
+
1426
+ **Options**: `*actions` (none = catch-all; the first matching rule wins), `secret:` (String, callable `instance_exec`'d per request, or Array for rotation — any match passes), `scheme:` (`:hex`), `header:` (overrides the preset), `tolerance:` (Stripe only, `300`s default), `digest:` (`:sha256`; `:sha1`/`:sha512` for `:hex`/`:base64` only).
1427
+
1428
+ **Notes**
1429
+ - Comparison is constant-time and the attacker-controlled header is **never decoded** — garbage (including invalid UTF-8 bytes) just fails with 401, it cannot raise.
1430
+ - A secret that resolves **blank at request time raises `ArgumentError`** — a misconfigured endpoint should page you, not 401 into the provider's silent retry loop.
1431
+ - Failure codes: `webhook_signature_missing` / `webhook_signature_invalid` / `webhook_timestamp_stale` → 401; `webhook_signature_malformed` (unparseable Stripe header) → 400. With `Respondable`, bodies delegate to `render_error`; override `webhook_verification_failed` to customize.
1432
+ - Declare **before** `Idempotentable` (a 401 cached by its around filter would be replayed) and before `Throttleable` (forged traffic shouldn't burn rate budget). Webhook endpoints also need `skip_before_action :verify_authenticity_token`.
1433
+ - In tests: `skip_before_action :verify_webhook_signature!`, or sign payloads for real with `OpenSSL::HMAC`. After a pass, `webhook_verified?` is true.
1434
+
1435
+ ---
1436
+
1364
1437
  ## 🗂️ Module paths & namespacing
1365
1438
 
1366
1439
  Every concern is available under two paths:
@@ -0,0 +1,323 @@
1
+ require "active_support/concern"
2
+ require "active_support/security_utils"
3
+ require "openssl"
4
+ require "digest"
5
+
6
+ module ConcernsOnRails
7
+ module Controllers
8
+ # HMAC signature verification for inbound webhooks — the receiving side of
9
+ # Stripe/GitHub/Shopify-style integrations. The action only runs when the
10
+ # signature over the raw request body verifies; otherwise a 401/400 is
11
+ # rendered and the action never executes.
12
+ #
13
+ # class WebhooksController < ApplicationController
14
+ # include ConcernsOnRails::Controllers::WebhookVerifiable
15
+ #
16
+ # verify_webhook :stripe, secret: -> { ENV["STRIPE_WEBHOOK_SECRET"] }, scheme: :stripe
17
+ # verify_webhook :github, secret: -> { ENV["GITHUB_WEBHOOK_SECRET"] }, scheme: :github
18
+ # verify_webhook :shopify, secret: [NEW_SECRET, OLD_SECRET], scheme: :shopify
19
+ # verify_webhook :custom, secret: "s3cr3t", scheme: :hex, header: "X-Acme-Signature"
20
+ # # verify_webhook secret: ... # no actions = catch-all (declare specific rules first)
21
+ #
22
+ # def stripe; ...; end
23
+ # end
24
+ #
25
+ # Schemes (header defaults in parentheses):
26
+ # :github ("X-Hub-Signature-256") value "sha256=<hex>"
27
+ # :shopify ("X-Shopify-Hmac-Sha256") value strict-Base64 of the binary HMAC
28
+ # :stripe ("Stripe-Signature") value "t=<unix>,v1=<hex>[,v1=...]"; the
29
+ # signed payload is "#{t}.#{body}"; every v1 is tried (rotation);
30
+ # `tolerance:` (default 300s, Stripe-only) rejects |now - t| beyond
31
+ # the window, replayed and far-future headers alike
32
+ # :hex / :base64 plain hex / strict-Base64 HMAC of the
33
+ # body; these have no standard header so `header:` is required;
34
+ # `digest:` (:sha256 default, :sha1/:sha512) applies to these only
35
+ #
36
+ # secret: a non-blank String, a callable (instance_exec'd per request — use
37
+ # `-> { ENV[...] }` for boot-order safety or read params for multi-tenant
38
+ # secrets), or an Array of those (rotation: any match passes). A secret that
39
+ # resolves blank at request time raises ArgumentError — a misconfigured
40
+ # endpoint must alert the operator, not 401 into the provider's silent
41
+ # retry loop.
42
+ #
43
+ # Failures render through `webhook_verification_failed(message:, status:,
44
+ # code:)` (delegates to Respondable's render_error when present; override
45
+ # it to customize): missing/blank header -> 401 "webhook_signature_missing";
46
+ # mismatch -> 401 "webhook_signature_invalid"; stale/future Stripe
47
+ # timestamp -> 401 "webhook_timestamp_stale"; unparseable Stripe header ->
48
+ # 400 "webhook_signature_malformed". After a pass, `webhook_verified?` is
49
+ # true.
50
+ #
51
+ # IMPORTANT:
52
+ # * Include/declare this BEFORE Idempotentable (and other around filters
53
+ # that cache responses) — a 401 that runs inside Idempotentable's
54
+ # around_action would be cached and replayed for the full ttl.
55
+ # Verifying before Throttleable also stops forged traffic from burning
56
+ # legitimate rate budget.
57
+ # * Webhook endpoints receive third-party POSTs: skip CSRF yourself
58
+ # (`skip_before_action :verify_authenticity_token`) along with any
59
+ # session auth filters.
60
+ # * The signature covers the raw bytes — parse `request.raw_post` in the
61
+ # action; re-serializing `params` may not round-trip byte-for-byte.
62
+ # Anything that rewrites the body before the controller breaks
63
+ # verification.
64
+ # * In tests, `skip_before_action :verify_webhook_signature!` or sign the
65
+ # payload for real with OpenSSL::HMAC.
66
+ module WebhookVerifiable
67
+ extend ActiveSupport::Concern
68
+
69
+ LABEL = "ConcernsOnRails::Controllers::WebhookVerifiable".freeze
70
+ SCHEMES = {
71
+ hex: { header: nil, encoding: :hex },
72
+ base64: { header: nil, encoding: :base64 },
73
+ github: { header: "X-Hub-Signature-256", encoding: :hex, prefix: "sha256=" },
74
+ shopify: { header: "X-Shopify-Hmac-Sha256", encoding: :base64 },
75
+ stripe: { header: "Stripe-Signature", encoding: :stripe }
76
+ }.freeze
77
+ # Schemes whose wire format pins the digest — `digest:` cannot override it.
78
+ PINNED_DIGEST_SCHEMES = %i[github shopify stripe].freeze
79
+ SUPPORTED_DIGESTS = { sha256: "SHA256", sha1: "SHA1", sha512: "SHA512" }.freeze
80
+ STRIPE_DEFAULT_TOLERANCE = 300 # seconds; Stripe's recommended window
81
+ # Stripe sends at most two v1 values (during secret rolls); the cap is
82
+ # cheap hygiene against a header stuffed with thousands of candidates.
83
+ MAX_STRIPE_SIGNATURES = 16
84
+ STRIPE_TIMESTAMP_FORMAT = /\A\d+\z/
85
+
86
+ included do
87
+ class_attribute :webhook_rules, instance_accessor: false, default: []
88
+ before_action :verify_webhook_signature!
89
+ end
90
+
91
+ module ClassMethods
92
+ # Declare signature verification for the given actions (none =
93
+ # catch-all). Each call appends a rule; the FIRST rule matching the
94
+ # current action wins, so declare specific rules before a catch-all.
95
+ def verify_webhook(*actions, secret:, scheme: :hex, header: nil, tolerance: nil, digest: :sha256)
96
+ actions = actions.flatten.map(&:to_s)
97
+ scheme = scheme.to_sym
98
+ digest = digest.to_sym
99
+ validate_verify_webhook!(secret: secret, scheme: scheme, header: header, tolerance: tolerance, digest: digest)
100
+
101
+ rule = { actions: actions, secret: secret, scheme: scheme,
102
+ header: (header || SCHEMES[scheme][:header]).to_s,
103
+ tolerance: scheme == :stripe ? (tolerance || STRIPE_DEFAULT_TOLERANCE).to_i : nil,
104
+ digest: digest }
105
+ self.webhook_rules = webhook_rules + [rule]
106
+ end
107
+
108
+ private
109
+
110
+ def validate_verify_webhook!(secret:, scheme:, header:, tolerance:, digest:)
111
+ raise ArgumentError, "#{LABEL}: unknown scheme :#{scheme} (supported: #{SCHEMES.keys.join(', ')})" unless SCHEMES.key?(scheme)
112
+ unless valid_webhook_secret?(secret)
113
+ raise ArgumentError, "#{LABEL}: :secret must be a non-blank String, a callable, or a non-empty Array of those"
114
+ end
115
+
116
+ validate_webhook_header!(scheme, header)
117
+ validate_webhook_tolerance!(scheme, tolerance)
118
+ validate_webhook_digest!(scheme, digest)
119
+ end
120
+
121
+ def validate_webhook_header!(scheme, header)
122
+ if header.nil?
123
+ return if SCHEMES[scheme][:header]
124
+
125
+ # No industry-standard generic header exists; guessing one would
126
+ # silently 401 every request. Fail at declaration time instead.
127
+ raise ArgumentError, "#{LABEL}: scheme :#{scheme} requires an explicit :header"
128
+ end
129
+ raise ArgumentError, "#{LABEL}: :header must be a non-blank String" if header.to_s.strip.empty?
130
+ end
131
+
132
+ def validate_webhook_tolerance!(scheme, tolerance)
133
+ return if tolerance.nil?
134
+ raise ArgumentError, "#{LABEL}: :tolerance only applies to scheme :stripe" unless scheme == :stripe
135
+ raise ArgumentError, "#{LABEL}: :tolerance must be a positive duration" unless tolerance.to_i.positive?
136
+ end
137
+
138
+ def validate_webhook_digest!(scheme, digest)
139
+ unless SUPPORTED_DIGESTS.key?(digest)
140
+ raise ArgumentError, "#{LABEL}: unsupported digest :#{digest} (supported: #{SUPPORTED_DIGESTS.keys.join(', ')})"
141
+ end
142
+ return unless digest != :sha256 && PINNED_DIGEST_SCHEMES.include?(scheme)
143
+
144
+ raise ArgumentError, "#{LABEL}: scheme :#{scheme} pins SHA256 — :digest cannot be overridden"
145
+ end
146
+
147
+ def valid_webhook_secret?(value)
148
+ case value
149
+ when Array then value.any? && value.all? { |entry| valid_webhook_secret?(entry) }
150
+ when String then !value.strip.empty?
151
+ else value.respond_to?(:call)
152
+ end
153
+ end
154
+ end
155
+
156
+ # before_action entry point. Public and named so apps can
157
+ # `skip_before_action :verify_webhook_signature!` (e.g. in tests).
158
+ def verify_webhook_signature!
159
+ rule = webhook_rule_for_action
160
+ return unless rule
161
+
162
+ value = read_webhook_header(rule)
163
+ if value.nil?
164
+ return webhook_verification_failed(message: "#{rule[:header]} header is missing.",
165
+ status: :unauthorized, code: "webhook_signature_missing")
166
+ end
167
+
168
+ secrets = resolve_webhook_secrets!(rule)
169
+ webhook_render_outcome(rule, webhook_verification_outcome(rule, value, secrets))
170
+ end
171
+
172
+ # True once the current request's signature has verified.
173
+ def webhook_verified?
174
+ !!@webhook_verified
175
+ end
176
+
177
+ # Single funnel for all failure outcomes (override point). Uses
178
+ # Respondable's render_error when available, otherwise the same inline
179
+ # envelope as Throttleable / Idempotentable.
180
+ def webhook_verification_failed(message:, status:, code:)
181
+ return unless respond_to?(:response) && response
182
+
183
+ return render_error(message: message, status: status, code: code) if respond_to?(:render_error)
184
+
185
+ render json: { success: false, error: { message: message, code: code } }, status: status
186
+ end
187
+
188
+ private
189
+
190
+ def webhook_rule_for_action
191
+ action = respond_to?(:action_name) ? action_name.to_s : nil
192
+ return nil unless action
193
+
194
+ self.class.webhook_rules.find { |rule| rule[:actions].empty? || rule[:actions].include?(action) }
195
+ end
196
+
197
+ def webhook_render_outcome(rule, outcome)
198
+ case outcome
199
+ when :ok
200
+ @webhook_verified = true
201
+ nil
202
+ when :malformed
203
+ webhook_verification_failed(message: "#{rule[:header]} header could not be parsed.",
204
+ status: :bad_request, code: "webhook_signature_malformed")
205
+ when :stale
206
+ webhook_verification_failed(message: "#{rule[:header]} timestamp is outside the allowed tolerance.",
207
+ status: :unauthorized, code: "webhook_timestamp_stale")
208
+ else
209
+ webhook_verification_failed(message: "#{rule[:header]} signature does not match the request body.",
210
+ status: :unauthorized, code: "webhook_signature_invalid")
211
+ end
212
+ end
213
+
214
+ def webhook_verification_outcome(rule, value, secrets)
215
+ return verify_stripe_signature(rule, value, secrets) if rule[:scheme] == :stripe
216
+
217
+ body = webhook_raw_body
218
+ matched = secrets.any? do |secret|
219
+ webhook_secure_compare(value, compute_webhook_signature(rule, secret, body))
220
+ end
221
+ matched ? :ok : :invalid
222
+ end
223
+
224
+ # The expected value is always ENCODED and compared as a string — the
225
+ # attacker-controlled header is never hex/Base64-decoded, so garbage
226
+ # input cannot raise, it just fails the comparison.
227
+ def compute_webhook_signature(rule, secret, payload)
228
+ preset = SCHEMES[rule[:scheme]]
229
+ digest = OpenSSL::Digest.new(SUPPORTED_DIGESTS[rule[:digest]])
230
+ case preset[:encoding]
231
+ when :hex
232
+ "#{preset[:prefix]}#{OpenSSL::HMAC.hexdigest(digest, secret, payload)}"
233
+ when :base64
234
+ # pack("m0") = strict Base64, no trailing newline. Avoids the base64
235
+ # gem, which is no longer a default gem on Ruby 3.4.
236
+ [OpenSSL::HMAC.digest(digest, secret, payload)].pack("m0")
237
+ end
238
+ end
239
+
240
+ def verify_stripe_signature(rule, value, secrets)
241
+ parsed = parse_stripe_header(value)
242
+ return :malformed unless parsed
243
+
244
+ # Symmetric window: rejects replayed (old t) and pre-dated (future t)
245
+ # headers alike. Time.now so travel_to works in specs.
246
+ return :stale if (Time.now.to_i - parsed[:timestamp]).abs > rule[:tolerance]
247
+
248
+ payload = "#{parsed[:timestamp]}.#{webhook_raw_body}"
249
+ expected = secrets.map { |secret| OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new("SHA256"), secret, payload) }
250
+ matched = parsed[:signatures].any? { |sig| expected.any? { |exp| webhook_secure_compare(sig, exp) } }
251
+ matched ? :ok : :invalid
252
+ end
253
+
254
+ # "t=<unix>,v1=<hex>[,v1=...]" — unknown keys (v0 etc.) are ignored. The
255
+ # FIRST valid t wins and feeds both the tolerance check and the signed
256
+ # payload, so appending a fresh t to a captured stale header cannot
257
+ # resurrect it (its v1 was computed over the original t).
258
+ def parse_stripe_header(value)
259
+ pairs = value.split(",").map do |pair|
260
+ key, val = pair.split("=", 2)
261
+ [key.to_s.strip, val.to_s.strip]
262
+ end
263
+ timestamp = stripe_timestamp(pairs)
264
+ signatures = stripe_signatures(pairs)
265
+ return nil unless timestamp && signatures.any?
266
+
267
+ { timestamp: timestamp, signatures: signatures }
268
+ end
269
+
270
+ def stripe_timestamp(pairs)
271
+ _, value = pairs.find { |key, val| key == "t" && val.match?(STRIPE_TIMESTAMP_FORMAT) }
272
+ value&.to_i
273
+ end
274
+
275
+ def stripe_signatures(pairs)
276
+ pairs.select { |key, val| key == "v1" && !val.empty? }
277
+ .first(MAX_STRIPE_SIGNATURES)
278
+ .map { |_, val| val }
279
+ end
280
+
281
+ def read_webhook_header(rule)
282
+ return nil unless respond_to?(:request) && request.respond_to?(:headers) && request.headers
283
+
284
+ # scrub first: an invalid-UTF-8 byte in the attacker-controlled header
285
+ # must fail the comparison, not raise Encoding::CompatibilityError out
286
+ # of strip / regexp matching (a user-triggerable 500).
287
+ value = request.headers[rule[:header]].to_s.scrub.strip
288
+ value.empty? ? nil : value
289
+ end
290
+
291
+ def webhook_raw_body
292
+ return "" unless respond_to?(:request) && request.respond_to?(:raw_post)
293
+
294
+ request.raw_post.to_s
295
+ end
296
+
297
+ # Callables are instance_exec'd per request (multi-tenant secrets can
298
+ # read params); an Array means rotation. Resolving blank is a server
299
+ # misconfiguration — raise loudly rather than 401 every delivery.
300
+ def resolve_webhook_secrets!(rule)
301
+ resolved = Array(rule[:secret]).flat_map do |candidate|
302
+ Array(candidate.respond_to?(:call) ? instance_exec(&candidate) : candidate)
303
+ end
304
+ raise_blank_webhook_secret! if resolved.empty? || resolved.any? { |secret| secret.to_s.strip.empty? }
305
+ resolved.map(&:to_s)
306
+ end
307
+
308
+ def raise_blank_webhook_secret!
309
+ action = respond_to?(:action_name) ? action_name : "unknown"
310
+ raise ArgumentError, "#{LABEL}: :secret resolved blank for action '#{action}' — " \
311
+ "verification cannot proceed with an empty secret."
312
+ end
313
+
314
+ # Constant-time comparison, portable across Rails 5.0-8: both sides are
315
+ # collapsed to fixed-length SHA256 digests first (pre-5.2 secure_compare
316
+ # short-circuited on length mismatch; 5.2+ digests internally — double
317
+ # digesting is harmless).
318
+ def webhook_secure_compare(given, expected)
319
+ ActiveSupport::SecurityUtils.secure_compare(::Digest::SHA256.digest(given), ::Digest::SHA256.digest(expected))
320
+ end
321
+ end
322
+ end
323
+ end
@@ -21,4 +21,5 @@ module ConcernsOnRails
21
21
  Maskable = Models::Maskable
22
22
  Monetizable = Models::Monetizable
23
23
  Auditable = Models::Auditable
24
+ Lockable = Models::Lockable
24
25
  end
@@ -0,0 +1,317 @@
1
+ require "active_support/concern"
2
+
3
+ module ConcernsOnRails
4
+ module Models
5
+ # Failed-attempt tracking + account lockout ("Devise lockable-lite") for
6
+ # apps rolling their own authentication (Rails 8 generator,
7
+ # has_secure_password) — which ships no brute-force protection at all.
8
+ # Two columns on the model's own table, no tokens, no mailers.
9
+ #
10
+ # class User < ApplicationRecord
11
+ # include ConcernsOnRails::Lockable
12
+ #
13
+ # lockable_by max_attempts: 5, unlock_in: 15.minutes
14
+ # # lockable_by attempts: :failed_logins, locked_at: :locked_until_at,
15
+ # # prefix: :account # => .account_locked / .account_unlocked
16
+ # end
17
+ #
18
+ # user.register_failed_attempt! # atomic SQL increment; locks at max_attempts
19
+ # user.access_locked? # true while locked (expires after unlock_in)
20
+ # user.attempts_remaining # for "3 attempts remaining" flash messages
21
+ # user.reset_failed_attempts! # call on successful login
22
+ # user.lock_access! / user.unlock_access!
23
+ # User.locked / User.unlocked # expiry-aware scopes
24
+ #
25
+ # Notes:
26
+ # * `unlock_in: nil` (the default) means locked until unlock_access! is
27
+ # called; with a duration, the lock lapses by itself. Expiry is lazy —
28
+ # readers and scopes treat a stale `locked_at` as unlocked but never
29
+ # write — so the column is cleared on the next unlock_access! or
30
+ # register_failed_attempt! (quietly there: no unlock hooks fire from a
31
+ # failed login). The expiry instant itself counts as unlocked.
32
+ # * register_failed_attempt! increments with update_counters — a single
33
+ # SQL-side `COALESCE(attempts, 0) + 1`, so concurrent failures never
34
+ # lose updates (in-Ruby increment! is read-modify-write before Rails
35
+ # 5.2) and a NULL counter needs no column default. While the account is
36
+ # locked it stops counting and returns the current count unchanged.
37
+ # Two requests crossing the threshold at the same instant may each fire
38
+ # after_lock once (same property as Devise).
39
+ # * lock_access!/unlock_access! persist via update_columns: validations
40
+ # and AR callbacks are bypassed on purpose, so an otherwise-invalid
41
+ # record can still be locked. That also skips updated_at and means a
42
+ # coexisting Auditable will not record the change. Hooks (before/
43
+ # after_lock, before/after_unlock) run in a transaction — a raising
44
+ # hook rolls the write back. reset_failed_attempts! fires no hooks.
45
+ # * All bang methods raise ArgumentError on unsaved records.
46
+ # * Reach for Devise's lockable when you need unlock tokens, unlock
47
+ # emails, or per-strategy unlocks.
48
+ module Lockable
49
+ extend ActiveSupport::Concern
50
+
51
+ LABEL = "ConcernsOnRails::Models::Lockable".freeze
52
+ DEFAULT_ATTEMPTS_FIELD = :failed_attempts
53
+ DEFAULT_LOCKED_AT_FIELD = :locked_at
54
+ DEFAULT_MAX_ATTEMPTS = 5
55
+
56
+ included do
57
+ class_attribute :lockable_attempts_field, instance_accessor: false, default: DEFAULT_ATTEMPTS_FIELD
58
+ class_attribute :lockable_locked_at_field, instance_accessor: false, default: DEFAULT_LOCKED_AT_FIELD
59
+ class_attribute :lockable_max_attempts, instance_accessor: false, default: DEFAULT_MAX_ATTEMPTS
60
+ class_attribute :lockable_unlock_in, instance_accessor: false, default: nil
61
+ end
62
+
63
+ module ClassMethods
64
+ include ConcernsOnRails::Support::ColumnGuard
65
+
66
+ # Configure the lockout columns and policy. See the module docs.
67
+ def lockable_by(attempts: DEFAULT_ATTEMPTS_FIELD, locked_at: DEFAULT_LOCKED_AT_FIELD,
68
+ max_attempts: DEFAULT_MAX_ATTEMPTS, unlock_in: nil, prefix: nil, suffix: nil)
69
+ attempts = attempts.to_sym
70
+ locked_at = locked_at.to_sym
71
+ validate_lockable!(attempts, locked_at, max_attempts: max_attempts, unlock_in: unlock_in)
72
+
73
+ self.lockable_attempts_field = attempts
74
+ self.lockable_locked_at_field = locked_at
75
+ self.lockable_max_attempts = max_attempts
76
+ self.lockable_unlock_in = unlock_in
77
+ ensure_columns!(LABEL, attempts, locked_at)
78
+ validate_lockable_attempts_column!(attempts)
79
+ define_lockable_scopes(prefix, suffix)
80
+ end
81
+
82
+ private
83
+
84
+ def validate_lockable!(attempts, locked_at, max_attempts:, unlock_in:)
85
+ raise ArgumentError, "#{LABEL}: attempts and locked_at must be different columns" if attempts == locked_at
86
+ unless positive_integer_or_nil?(max_attempts)
87
+ raise ArgumentError, "#{LABEL}: max_attempts must be a positive Integer or nil (nil = never auto-lock)"
88
+ end
89
+ return if positive_duration_or_nil?(unlock_in)
90
+
91
+ raise ArgumentError, "#{LABEL}: unlock_in must be a positive duration (e.g. 15.minutes) or nil"
92
+ end
93
+
94
+ # The increment happens in SQL arithmetic, so the column must really be
95
+ # an integer — a string column would "work" on SQLite and corrupt
96
+ # silently elsewhere. (locked_at is not type-checked: the AR type
97
+ # symbol for timestamp columns varies by adapter and Rails version.)
98
+ def validate_lockable_attempts_column!(attempts)
99
+ return if columns_hash[attempts.to_s]&.type == :integer
100
+
101
+ raise ArgumentError, "#{LABEL}: '#{attempts}' must be an integer column"
102
+ end
103
+
104
+ # Scopes live here (not in `included do`) so their names can be affixed
105
+ # via prefix:/suffix: — letting `.locked` coexist with a same-named
106
+ # scope from another source on one model. Lambdas branch on the
107
+ # configuration and compute the cutoff in Ruby at call time, so the
108
+ # predicate stays portable (no adapter-specific SQL date math).
109
+ def define_lockable_scopes(prefix, suffix)
110
+ scope lockable_scope_name(:locked, prefix, suffix), lambda {
111
+ field = lockable_locked_at_field
112
+ if lockable_unlock_in
113
+ where(arel_table[field].gt(Time.zone.now - lockable_unlock_in))
114
+ else
115
+ where.not(field => nil)
116
+ end
117
+ }
118
+ scope lockable_scope_name(:unlocked, prefix, suffix), lambda {
119
+ field = lockable_locked_at_field
120
+ if lockable_unlock_in
121
+ column = arel_table[field]
122
+ where(column.eq(nil).or(column.lteq(Time.zone.now - lockable_unlock_in)))
123
+ else
124
+ where(field => nil)
125
+ end
126
+ }
127
+ end
128
+
129
+ def lockable_scope_name(base, prefix, suffix)
130
+ [prefix, base, suffix].compact.join("_").to_sym
131
+ end
132
+
133
+ def positive_integer_or_nil?(value)
134
+ value.nil? || (value.is_a?(Integer) && value.positive?)
135
+ end
136
+
137
+ def positive_duration_or_nil?(value)
138
+ return true if value.nil?
139
+
140
+ (value.is_a?(ActiveSupport::Duration) || value.is_a?(Numeric)) && value.to_f.positive?
141
+ end
142
+ end
143
+
144
+ # ---- lifecycle hooks (override in the model) ----
145
+ # after_lock is the place for "your account has been locked" emails.
146
+ def before_lock; end
147
+ def after_lock; end
148
+ def before_unlock; end
149
+ def after_unlock; end
150
+
151
+ # ---- instance methods ----
152
+
153
+ # Record one failed authentication attempt and auto-lock at the
154
+ # threshold. Returns the fresh post-increment count (handy for
155
+ # "N attempts remaining" messaging); check access_locked? for the
156
+ # lock decision. While locked it neither counts nor re-locks — that
157
+ # branch returns the current in-memory count unchanged.
158
+ def register_failed_attempt!
159
+ lockable_guard_persisted!("register_failed_attempt!")
160
+ return lockable_current_attempts if access_locked?
161
+
162
+ # A lapsed lock is cleared quietly — this is a *failed* login, so
163
+ # firing unlock hooks ("account unlocked" notifications) would be
164
+ # wrong. The failure below then counts as attempt 1 of the new window.
165
+ lockable_clear_expired_lock! if lock_expired?
166
+
167
+ self.class.update_counters(id, self.class.lockable_attempts_field => 1)
168
+ fresh = lockable_fresh_attempts_count
169
+ lockable_sync_attempts(fresh)
170
+
171
+ max = self.class.lockable_max_attempts
172
+ lock_access! if max && fresh >= max
173
+ fresh
174
+ end
175
+
176
+ # Lock now (update_columns — no validations/callbacks). Idempotent while
177
+ # locked; an expired lock is re-locked with a fresh timestamp. Returns
178
+ # true, or false when a hook aborted the write via ActiveRecord::Rollback.
179
+ def lock_access!
180
+ lockable_guard_persisted!("lock_access!")
181
+ return true if access_locked?
182
+
183
+ field = self.class.lockable_locked_at_field
184
+ lockable_write_with_hooks(field => self[field]) do
185
+ before_lock
186
+ update_columns(field => Time.zone.now)
187
+ after_lock
188
+ end
189
+ end
190
+
191
+ # Clear the lock and zero the counter in one write. Fires unlock hooks.
192
+ # Returns true, or false when a hook aborted via ActiveRecord::Rollback.
193
+ def unlock_access!
194
+ lockable_guard_persisted!("unlock_access!")
195
+ locked_field = self.class.lockable_locked_at_field
196
+ return true if self[locked_field].nil?
197
+
198
+ attempts_field = self.class.lockable_attempts_field
199
+ lockable_write_with_hooks(locked_field => self[locked_field],
200
+ attempts_field => self[attempts_field]) do
201
+ before_unlock
202
+ update_columns(locked_field => nil, attempts_field => 0)
203
+ after_unlock
204
+ end
205
+ end
206
+
207
+ # Successful-login path: zero the counter, leave any lock untouched,
208
+ # fire no hooks. (Unlocking is a separate, deliberate act.) The
209
+ # already-zero short-circuit saves a write per successful login.
210
+ def reset_failed_attempts!
211
+ lockable_guard_persisted!("reset_failed_attempts!")
212
+ return true if lockable_current_attempts.zero?
213
+
214
+ update_column(self.class.lockable_attempts_field, 0)
215
+ true
216
+ end
217
+
218
+ # Locked right now? Lazy expiry: a stale locked_at reads as unlocked but
219
+ # is never cleared here — readers stay side-effect free.
220
+ def access_locked?
221
+ self[self.class.lockable_locked_at_field].present? && !lock_expired?
222
+ end
223
+
224
+ # Was locked, and the unlock_in window has fully elapsed. Always false
225
+ # when unlock_in is nil (manual unlock only). The boundary instant
226
+ # counts as expired (= unlocked), matching the scopes.
227
+ def lock_expired?
228
+ unlock_in = self.class.lockable_unlock_in
229
+ return false unless unlock_in
230
+
231
+ locked_at = self[self.class.lockable_locked_at_field]
232
+ return false if locked_at.nil?
233
+
234
+ locked_at <= Time.zone.now - unlock_in
235
+ end
236
+
237
+ # When the current lock lapses, or nil (not locked, or manual-only).
238
+ def lock_expires_at
239
+ unlock_in = self.class.lockable_unlock_in
240
+ locked_at = self[self.class.lockable_locked_at_field]
241
+ return nil if unlock_in.nil? || locked_at.nil?
242
+
243
+ locked_at + unlock_in
244
+ end
245
+
246
+ # Failures left before auto-lock (never negative); nil when
247
+ # max_attempts is nil (counting without auto-lock).
248
+ def attempts_remaining
249
+ max = self.class.lockable_max_attempts
250
+ return nil unless max
251
+
252
+ [max - lockable_current_attempts, 0].max
253
+ end
254
+
255
+ private
256
+
257
+ def lockable_guard_persisted!(operation)
258
+ raise ArgumentError, "#{LABEL}: #{operation} cannot be called on a new record" if new_record?
259
+ end
260
+
261
+ # Run hooks + update_columns inside a transaction, restoring the
262
+ # in-memory attributes when the block doesn't complete. update_columns
263
+ # syncs the attribute cache immediately and a transaction ROLLBACK does
264
+ # not undo that — without the restore, a raising after_lock would leave
265
+ # access_locked? true in memory while the row stays unlocked, and every
266
+ # retry would no-op on the idempotency guard. ensure (not rescue) so a
267
+ # throwing hook is covered too. Returns the completion flag, not a
268
+ # blanket true: a hook raising ActiveRecord::Rollback is swallowed by
269
+ # `transaction`, and the caller must see false — not a fake success —
270
+ # when nothing was written.
271
+ def lockable_write_with_hooks(previous_values)
272
+ completed = false
273
+ begin
274
+ transaction do
275
+ yield
276
+ completed = true
277
+ end
278
+ ensure
279
+ unless completed
280
+ previous_values.each { |field, value| self[field] = value }
281
+ send(:clear_attribute_changes, previous_values.keys.map(&:to_s))
282
+ end
283
+ end
284
+ completed
285
+ end
286
+
287
+ def lockable_current_attempts
288
+ self[self.class.lockable_attempts_field] || 0
289
+ end
290
+
291
+ # No hooks on purpose — see register_failed_attempt!.
292
+ def lockable_clear_expired_lock!
293
+ update_columns(self.class.lockable_locked_at_field => nil,
294
+ self.class.lockable_attempts_field => 0)
295
+ end
296
+
297
+ # Read the post-increment count back. unscoped, so a coexisting
298
+ # default_scope (e.g. SoftDeletable's) cannot hide the row. nil (row
299
+ # deleted concurrently) collapses to 0.
300
+ def lockable_fresh_attempts_count
301
+ self.class.unscoped.where(self.class.primary_key => id)
302
+ .pluck(self.class.lockable_attempts_field).first.to_i
303
+ end
304
+
305
+ # Mirror the SQL-side increment into the in-memory attribute without
306
+ # leaving it dirty — otherwise a later save would write the counter
307
+ # again (and a coexisting Auditable would record a phantom change).
308
+ # clear_attribute_changes flips public/private across Rails versions,
309
+ # hence send.
310
+ def lockable_sync_attempts(fresh)
311
+ field = self.class.lockable_attempts_field
312
+ self[field] = fresh
313
+ send(:clear_attribute_changes, [field.to_s])
314
+ end
315
+ end
316
+ end
317
+ end
@@ -1,3 +1,3 @@
1
1
  module ConcernsOnRails
2
- VERSION = "1.16.0".freeze
2
+ VERSION = "1.17.0".freeze
3
3
  end
@@ -36,6 +36,7 @@ require "concerns_on_rails/models/sanitizable"
36
36
  require "concerns_on_rails/models/maskable"
37
37
  require "concerns_on_rails/models/monetizable"
38
38
  require "concerns_on_rails/models/auditable"
39
+ require "concerns_on_rails/models/lockable"
39
40
 
40
41
  # Controller concerns
41
42
  require "concerns_on_rails/controllers/paginatable"
@@ -50,6 +51,7 @@ require "concerns_on_rails/controllers/authorizable"
50
51
  require "concerns_on_rails/controllers/throttleable"
51
52
  require "concerns_on_rails/controllers/timezoneable"
52
53
  require "concerns_on_rails/controllers/idempotentable"
54
+ require "concerns_on_rails/controllers/webhook_verifiable"
53
55
 
54
56
  # Backwards compatibility (top-level aliases for pre-1.6 module paths)
55
57
  require "concerns_on_rails/legacy_aliases"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: concerns_on_rails
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.16.0
4
+ version: 1.17.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ethan Nguyen
@@ -82,12 +82,14 @@ files:
82
82
  - lib/concerns_on_rails/controllers/sortable.rb
83
83
  - lib/concerns_on_rails/controllers/throttleable.rb
84
84
  - lib/concerns_on_rails/controllers/timezoneable.rb
85
+ - lib/concerns_on_rails/controllers/webhook_verifiable.rb
85
86
  - lib/concerns_on_rails/legacy_aliases.rb
86
87
  - lib/concerns_on_rails/models/activatable.rb
87
88
  - lib/concerns_on_rails/models/addressable.rb
88
89
  - lib/concerns_on_rails/models/auditable.rb
89
90
  - lib/concerns_on_rails/models/expirable.rb
90
91
  - lib/concerns_on_rails/models/hashable.rb
92
+ - lib/concerns_on_rails/models/lockable.rb
91
93
  - lib/concerns_on_rails/models/maskable.rb
92
94
  - lib/concerns_on_rails/models/monetizable.rb
93
95
  - lib/concerns_on_rails/models/normalizable.rb