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 +4 -4
- data/CHANGELOG.md +8 -0
- data/README.md +74 -1
- data/lib/concerns_on_rails/controllers/webhook_verifiable.rb +323 -0
- data/lib/concerns_on_rails/legacy_aliases.rb +1 -0
- data/lib/concerns_on_rails/models/lockable.rb +317 -0
- data/lib/concerns_on_rails/version.rb +1 -1
- data/lib/concerns_on_rails.rb +2 -0
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 5759f8cf9f86e11087369181f53f807875909047c17fe8abb19a5b6bafaae697
|
|
4
|
+
data.tar.gz: d6e34235431b2c54a36de7126b4829b9892b05f66f0fc6b476ff561731452100
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
- **
|
|
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
|
|
@@ -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
|
data/lib/concerns_on_rails.rb
CHANGED
|
@@ -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.
|
|
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
|