verify_it 0.4.2 → 0.5.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: '0638bdc85958eb337d3239d426d9b68a640d1130614406711dad37c1da11c7a2'
4
- data.tar.gz: 7c40e0432c8e2627c26cbfac91b32f13358fa13a476cb22bc922d3fa1d5db897
3
+ metadata.gz: be733b2c9a751f9163ffcc41c67a22c88b19a92baf8e8e5d478f0d2cfb3c1c3b
4
+ data.tar.gz: fb2580dd55fda49704fa8c327721eda31d470fff6f8f4177adb3741d24d89553
5
5
  SHA512:
6
- metadata.gz: 159d3ae1264b6095b229c4d5d24a925074c1a49c7be7894b44d1c9a8dfb5f87e840f569f5b54ba9d6b968d3092d34dfe1e9d2d5fb608101d1a730c75f572421d
7
- data.tar.gz: 46c8621291849844500dfb31753870e7d7de574dbb178c1db318fe64a680cf612110361b162479cb5521047f319d8e609443f5f1f1a7d022a26d5cd0ec8c0fd5
6
+ metadata.gz: d87fdcf7e40044a25259f945151644337b21d6e404e6fc793a3b9d856f745da23fe1014e34a7240d1386ca24a7e096b2233c19548e0ba8a86470faf98eb459db
7
+ data.tar.gz: bf5ef12903fe759e87de6ff9cd6bda4cb53b4abe3fe5c71aaae00c63a0efd3f2290abdff32706e0b7868e276a68580d78af847a15d82beab19531a522703e5dc
data/CHANGELOG.md CHANGED
@@ -1,3 +1,36 @@
1
+ ## [0.5.0] - 2026-03-22
2
+
3
+ ### Added
4
+ - **IP-based rate limiting** — new opt-in configuration options `max_ip_send_attempts`
5
+ and `max_ip_verification_attempts` protect against SMS pumping, distributed brute
6
+ force, and IP-based abuse. Both default to `nil` (disabled). When configured, IP
7
+ attempts are tracked using the existing storage backends with no schema changes required.
8
+ - `Verifier#send_code` now accepts an optional `request:` keyword, matching
9
+ `verify_code`. The engine controller and `Verifiable` concern forward it automatically.
10
+ - `RateLimiter` gains `ip_send_rate_limited?`, `ip_verification_rate_limited?`,
11
+ `record_ip_send_attempt`, and `record_ip_verification_attempt` methods.
12
+ - `Verifiable#send_{channel}_code` now accepts an optional `request:` keyword.
13
+
14
+ ### Fixed
15
+ - **DatabaseStorage race conditions** — `store_code` and `increment_attempt_count`
16
+ now use atomic patterns with `rescue ActiveRecord::RecordNotUnique` + retry,
17
+ preventing duplicate records and rate limit bypass under concurrency.
18
+ - Migration template indexes on `verify_it_codes` and `verify_it_attempts` are now
19
+ `unique: true`, enforcing data integrity at the database level.
20
+ - `Attempt` model validation now accepts `ip_send` and `ip_verification` attempt types.
21
+
22
+ ### Changed
23
+ - README: generator commands now show the bare command first; `--storage` and
24
+ `--engine` are documented as opt-in flags with defaults explained
25
+ - README: moved Rails Engine section right after Rails Setup, before Plain Ruby,
26
+ so all Rails content is grouped together
27
+ - README: Plain Ruby examples use `record: user` instead of `record: current_user`
28
+ to avoid implying a Rails dependency
29
+ - README: Engine section notes that `--engine` already generates mount and resolver
30
+ config, so users don't duplicate setup
31
+ - README: Engine resolver example no longer hardcodes Redis storage; replaced with
32
+ a storage-agnostic comment pointing to the Configuration Reference
33
+
1
34
  ## [0.4.2] - 2026-03-21
2
35
 
3
36
  ### Added
data/README.md CHANGED
@@ -35,21 +35,21 @@ bundle install
35
35
  ### 1. Generate configuration files
36
36
 
37
37
  ```bash
38
- rails generate verify_it:install --storage=redis
38
+ rails generate verify_it:install
39
39
  ```
40
40
 
41
- The installer generates:
42
-
43
- - `config/initializers/verify_it.rb`your main configuration
44
- - A database migration (if you chose `--storage=database`)
41
+ Available options:
42
+ - `--storage=memory|redis|database` (default: `memory`)
43
+ - `--engine` — mount the VerifyIt engine and generate resolver config
45
44
 
46
- Pass `--engine` to also mount the engine routes and generate resolver config:
45
+ Examples:
47
46
 
48
47
  ```bash
49
- rails generate verify_it:install --storage=redis --engine
48
+ rails generate verify_it:install --storage=redis
49
+ rails generate verify_it:install --storage=database --engine
50
50
  ```
51
51
 
52
- If you chose database storage, run:
52
+ If you chose `--storage=database`, run the migration:
53
53
 
54
54
  ```bash
55
55
  rails db:migrate
@@ -165,32 +165,6 @@ current_user.send_email_code(context: {
165
165
 
166
166
  ---
167
167
 
168
- ## Plain Ruby
169
-
170
- ```ruby
171
- require "verify_it"
172
-
173
- VerifyIt.configure do |config|
174
- config.secret_key_base = ENV.fetch("VERIFY_IT_SECRET")
175
- config.storage = :memory # :memory, :redis, or :database
176
-
177
- config.sms_sender = ->(to:, code:, context:) {
178
- MySmsSender.deliver(to: to, body: "Your code: #{code}")
179
- }
180
- end
181
-
182
- # Send a code
183
- result = VerifyIt.send_code(to: "+15551234567", channel: :sms, record: current_user)
184
-
185
- # Verify a code
186
- result = VerifyIt.verify_code(to: "+15551234567", code: "123456", record: current_user)
187
-
188
- # Clean up
189
- VerifyIt.cleanup(to: "+15551234567", record: current_user)
190
- ```
191
-
192
- ---
193
-
194
168
  ## Rails Engine (HTTP Endpoints)
195
169
 
196
170
  VerifyIt ships a mountable Rails Engine that exposes two JSON endpoints. Use the
@@ -198,6 +172,8 @@ engine when you want drop-in endpoints without writing controller code. This is
198
172
  optional - skip this section if you prefer to handle verification in your own
199
173
  controllers using the `Verifiable` concern or the plain Ruby API.
200
174
 
175
+ > If you ran the installer with `--engine`, the mount and resolver config are already generated for you. The details below are for reference or manual setup.
176
+
201
177
  ### Mount the engine
202
178
 
203
179
  ```ruby
@@ -220,8 +196,7 @@ Two additional config options are **required** when using the engine endpoints:
220
196
  # config/initializers/verify_it.rb
221
197
  VerifyIt.configure do |config|
222
198
  config.secret_key_base = Rails.application.secret_key_base
223
- config.storage = :redis
224
- config.redis_client = Redis.new
199
+ # ... your storage config (see Configuration Reference) ...
225
200
 
226
201
  # Resolve the authenticated record from the request (e.g. from a session or JWT).
227
202
  config.current_record_resolver = ->(request) {
@@ -291,6 +266,32 @@ en:
291
266
 
292
267
  ---
293
268
 
269
+ ## Plain Ruby
270
+
271
+ ```ruby
272
+ require "verify_it"
273
+
274
+ VerifyIt.configure do |config|
275
+ config.secret_key_base = ENV.fetch("VERIFY_IT_SECRET")
276
+ config.storage = :memory # :memory, :redis, or :database
277
+
278
+ config.sms_sender = ->(to:, code:, context:) {
279
+ MySmsSender.deliver(to: to, body: "Your code: #{code}")
280
+ }
281
+ end
282
+
283
+ # Send a code
284
+ result = VerifyIt.send_code(to: "+15551234567", channel: :sms, record: user)
285
+
286
+ # Verify a code
287
+ result = VerifyIt.verify_code(to: "+15551234567", code: "123456", record: user)
288
+
289
+ # Clean up
290
+ VerifyIt.cleanup(to: "+15551234567", record: user)
291
+ ```
292
+
293
+ ---
294
+
294
295
  ## Configuration Reference
295
296
 
296
297
  | Option | Default | Accepted values |
@@ -304,6 +305,8 @@ en:
304
305
  | `max_send_attempts` | `3` | Integer |
305
306
  | `max_verification_attempts` | `5` | Integer |
306
307
  | `max_identifier_changes` | `5` | Integer |
308
+ | `max_ip_send_attempts` | `nil` | Integer or `nil` (disabled) — max sends per IP per window |
309
+ | `max_ip_verification_attempts` | `nil` | Integer or `nil` (disabled) — max verifications per IP per window |
307
310
  | `rate_limit_window` | `3600` | Seconds (integer) |
308
311
  | `delivery_channel` | `:sms` | `:sms`, `:email` |
309
312
  | `sms_sender` | `nil` | Lambda `(to:, code:, context:) { }` |
@@ -400,6 +403,16 @@ end
400
403
  - Use `:redis` or `:database` storage in production
401
404
  - Keep `code_ttl` short.
402
405
  - Use `on_verify_failure` to monitor and alert on repeated failures
406
+ - **IP-based rate limiting** — enable `max_ip_send_attempts` and `max_ip_verification_attempts` to protect against SMS pumping and distributed brute force attacks:
407
+
408
+ ```ruby
409
+ VerifyIt.configure do |config|
410
+ config.max_ip_send_attempts = 10 # max 10 sends per IP per window
411
+ config.max_ip_verification_attempts = 20 # max 20 verifications per IP per window
412
+ end
413
+ ```
414
+
415
+ IP rate limiting requires passing `request:` to `send_code`/`verify_code`. The engine controller does this automatically. For custom controllers, pass `request:` explicitly.
403
416
 
404
417
  ---
405
418
 
@@ -13,7 +13,7 @@ module VerifyIt
13
13
 
14
14
  channel = resolve_channel
15
15
  identifier = resolve_identifier(record, channel)
16
- result = VerifyIt.send_code(to: identifier, record: record, channel: channel, context: {})
16
+ result = VerifyIt.send_code(to: identifier, record: record, channel: channel, context: {}, request: request)
17
17
 
18
18
  if result.success?
19
19
  payload = { message: I18n.t("verify_it.responses.sent") }
@@ -9,6 +9,7 @@ en:
9
9
  code_not_found: "No active verification code found. Please request a new one."
10
10
  locked: "Your account is temporarily locked due to too many failed attempts."
11
11
  delivery_failed: "Failed to deliver verification code. Please try again."
12
+ ip_rate_limited: "Too many requests from this IP address. Please try again later."
12
13
  sms:
13
14
  default_message: "%{code} is your verification code."
14
15
  email:
@@ -8,7 +8,7 @@ class CreateVerifyItTables < ActiveRecord::Migration[<%= ActiveRecord::Migration
8
8
  t.datetime :expires_at, null: false
9
9
  t.timestamps
10
10
 
11
- t.index [:identifier, :record_type, :record_id], name: "index_verify_it_codes_on_record"
11
+ t.index [:identifier, :record_type, :record_id], name: "index_verify_it_codes_on_record", unique: true
12
12
  t.index :expires_at
13
13
  end
14
14
 
@@ -22,7 +22,7 @@ class CreateVerifyItTables < ActiveRecord::Migration[<%= ActiveRecord::Migration
22
22
  t.timestamps
23
23
 
24
24
  t.index [:identifier, :record_type, :record_id, :attempt_type],
25
- name: "index_verify_it_attempts_on_record_and_type"
25
+ name: "index_verify_it_attempts_on_record_and_type", unique: true
26
26
  t.index :expires_at
27
27
  end
28
28
 
@@ -21,6 +21,8 @@ module VerifyIt
21
21
  :test_mode,
22
22
  :bypass_delivery,
23
23
  :secret_key_base,
24
+ :max_ip_send_attempts,
25
+ :max_ip_verification_attempts,
24
26
  :current_record_resolver,
25
27
  :identifier_resolver
26
28
 
@@ -55,6 +57,8 @@ module VerifyIt
55
57
  @test_mode = false
56
58
  @bypass_delivery = false
57
59
  @secret_key_base = nil
60
+ @max_ip_send_attempts = nil
61
+ @max_ip_verification_attempts = nil
58
62
  @current_record_resolver = nil
59
63
  @identifier_resolver = nil
60
64
  end
@@ -64,5 +64,29 @@ module VerifyIt
64
64
  def reset_verification_attempts(identifier:, record:)
65
65
  @storage.reset_attempts(identifier: identifier, record: record)
66
66
  end
67
+
68
+ def ip_send_rate_limited?(ip:)
69
+ max = VerifyIt.configuration.max_ip_send_attempts
70
+ return false unless max
71
+
72
+ count = @storage.send_count(identifier: ip, record: nil)
73
+ count >= max
74
+ end
75
+
76
+ def ip_verification_rate_limited?(ip:)
77
+ max = VerifyIt.configuration.max_ip_verification_attempts
78
+ return false unless max
79
+
80
+ count = @storage.attempts(identifier: ip, record: nil)
81
+ count >= max
82
+ end
83
+
84
+ def record_ip_send_attempt(ip:)
85
+ @storage.increment_send_count(identifier: ip, record: nil)
86
+ end
87
+
88
+ def record_ip_verification_attempt(ip:)
89
+ @storage.increment_attempts(identifier: ip, record: nil)
90
+ end
67
91
  end
68
92
  end
@@ -25,13 +25,14 @@ module VerifyIt
25
25
  attrs[:record_id] = record.id
26
26
  end
27
27
 
28
- # Find existing or create new
29
28
  existing = find_code(identifier: identifier, record: record)
30
29
  if existing
31
- existing.update!(attrs)
30
+ existing.update!(code: code, expires_at: expires_at)
32
31
  else
33
32
  Models::Code.create!(attrs)
34
33
  end
34
+ rescue ActiveRecord::RecordNotUnique
35
+ retry
35
36
  end
36
37
 
37
38
  def fetch_code(identifier:, record:)
@@ -146,48 +147,41 @@ module VerifyIt
146
147
  end
147
148
 
148
149
  def find_attempt(identifier:, record:, attempt_type:)
150
+ build_attempt_scope(identifier:, record:, attempt_type:).first
151
+ end
152
+
153
+ def build_attempt_scope(identifier:, record:, attempt_type:)
149
154
  scope = Models::Attempt
150
155
  .for_identifier(identifier)
151
156
  .where(attempt_type: attempt_type)
152
157
 
153
158
  scope = scope.for_record(record) if record
154
- scope.first
159
+ scope
155
160
  end
156
161
 
157
162
  def increment_attempt_count(identifier:, record:, attempt_type:)
158
- attempt = find_attempt(
159
- identifier: identifier,
160
- record: record,
161
- attempt_type: attempt_type
162
- )
163
+ # Clean expired attempts first
164
+ build_attempt_scope(identifier:, record:, attempt_type:).expired.delete_all
163
165
 
164
166
  window = VerifyIt.configuration.rate_limit_window
165
167
  expires_at = Time.now + window
168
+ attrs = {
169
+ identifier: identifier.to_s,
170
+ attempt_type: attempt_type,
171
+ count: 0,
172
+ expires_at: expires_at
173
+ }
166
174
 
167
- if attempt.nil? || attempt.expired?
168
- # Create new attempt record
169
- attrs = {
170
- identifier: identifier.to_s,
171
- attempt_type: attempt_type,
172
- count: 1,
173
- expires_at: expires_at
174
- }
175
-
176
- if record
177
- attrs[:record_type] = record.class.name
178
- attrs[:record_id] = record.id
179
- end
180
-
181
- # Delete old expired attempt if exists
182
- attempt&.destroy
183
-
184
- Models::Attempt.create!(attrs)
185
- 1
186
- else
187
- # Increment existing
188
- attempt.increment!(:count)
189
- attempt.count
175
+ if record
176
+ attrs[:record_type] = record.class.name
177
+ attrs[:record_id] = record.id
190
178
  end
179
+
180
+ attempt = build_attempt_scope(identifier:, record:, attempt_type:).first_or_create!(attrs)
181
+ attempt.increment!(:count)
182
+ attempt.count
183
+ rescue ActiveRecord::RecordNotUnique
184
+ retry
191
185
  end
192
186
 
193
187
  def get_attempt_count(identifier:, record:, attempt_type:)
@@ -7,7 +7,7 @@ module VerifyIt
7
7
  self.table_name = "verify_it_attempts"
8
8
 
9
9
  validates :identifier, presence: true
10
- validates :attempt_type, presence: true, inclusion: { in: %w[verification send] }
10
+ validates :attempt_type, presence: true, inclusion: { in: %w[verification send ip_send ip_verification] }
11
11
  validates :count, presence: true, numericality: { only_integer: true, greater_than_or_equal_to: 0 }
12
12
  validates :expires_at, presence: true
13
13
 
@@ -20,13 +20,14 @@ module VerifyIt
20
20
 
21
21
  raise ArgumentError, "Method #{send_method} is already defined on #{name}" if method_defined?(send_method)
22
22
 
23
- define_method(send_method) do |context: {}|
23
+ define_method(send_method) do |context: {}, request: nil|
24
24
  identifier = send(attribute)
25
25
  VerifyIt.send_code(
26
26
  to: identifier,
27
27
  record: self,
28
28
  channel: channel,
29
- context: context
29
+ context: context,
30
+ request: request
30
31
  )
31
32
  end
32
33
 
@@ -11,9 +11,14 @@ module VerifyIt
11
11
  @rate_limiter = RateLimiter.new(storage)
12
12
  end
13
13
 
14
- def send_code(to:, record:, channel: nil, context: {})
14
+ def send_code(to:, record:, channel: nil, context: {}, request: nil)
15
15
  channel ||= config.delivery_channel
16
16
 
17
+ ip = extract_ip(request)
18
+ if ip && rate_limiter.ip_send_rate_limited?(ip: ip)
19
+ return rate_limited_result("IP address rate limited for sending")
20
+ end
21
+
17
22
  rate_limit_result = check_send_rate_limits(to: to, record: record)
18
23
  return rate_limit_result if rate_limit_result
19
24
 
@@ -27,10 +32,17 @@ module VerifyIt
27
32
  )
28
33
  return delivery_result if delivery_result
29
34
 
35
+ rate_limiter.record_ip_send_attempt(ip: ip) if ip
36
+
30
37
  finalize_send(to: to, record: record, channel: channel, code_data: code_data)
31
38
  end
32
39
 
33
40
  def verify_code(to:, code:, record:, request: nil)
41
+ ip = extract_ip(request)
42
+ if ip && rate_limiter.ip_verification_rate_limited?(ip: ip)
43
+ return rate_limited_result("IP address rate limited for verification")
44
+ end
45
+
34
46
  rate_limit_result = check_verification_rate_limit(to: to, record: record)
35
47
  return rate_limit_result if rate_limit_result
36
48
 
@@ -38,6 +50,7 @@ module VerifyIt
38
50
  return code_not_found_result unless stored_code
39
51
 
40
52
  current_attempts = rate_limiter.record_verification_attempt(identifier: to, record: record)
53
+ rate_limiter.record_ip_verification_attempt(ip: ip) if ip
41
54
 
42
55
  if code_matches?(code, stored_code)
43
56
  handle_verification_success(to: to, record: record, attempts: current_attempts, request: request)
@@ -159,6 +172,16 @@ module VerifyIt
159
172
  end
160
173
  end
161
174
 
175
+ def extract_ip(request)
176
+ return nil unless request
177
+
178
+ if request.respond_to?(:remote_ip)
179
+ request.remote_ip
180
+ elsif request.respond_to?(:ip)
181
+ request.ip
182
+ end
183
+ end
184
+
162
185
  def invoke_callback(callback, **args)
163
186
  callback&.call(**args)
164
187
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module VerifyIt
4
- VERSION = "0.4.2"
4
+ VERSION = "0.5.0"
5
5
  end
data/lib/verify_it.rb CHANGED
@@ -33,8 +33,8 @@ module VerifyIt
33
33
  yield(configuration)
34
34
  end
35
35
 
36
- def send_code(to:, record:, channel: nil, context: {})
37
- verifier.send_code(to: to, record: record, channel: channel, context: context)
36
+ def send_code(to:, record:, channel: nil, context: {}, request: nil)
37
+ verifier.send_code(to: to, record: record, channel: channel, context: context, request: request)
38
38
  end
39
39
 
40
40
  def verify_code(to:, code:, record:, request: nil)
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: verify_it
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.2
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jeremias Ramirez